From 1e11884fef23dd859c465cb69d4b36baa8b34e21 Mon Sep 17 00:00:00 2001 From: Macbook Date: Tue, 26 May 2026 00:51:07 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B6=94=EC=84=B8=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/goldenchart/auth/MenuIds.java | 2 +- .../controller/TrendSearchController.java | 41 + .../dto/TrendSearchConditionDto.java | 21 + .../goldenchart/dto/TrendSearchRequest.java | 60 ++ .../goldenchart/dto/TrendSearchResultDto.java | 24 + .../service/TrendSearchService.java | 704 +++++++++++++++ .../db/migration/V29__trend_search_menu.sql | 6 + frontend/src/App.css | 235 ++++- frontend/src/App.tsx | 13 + frontend/src/components/ChartSlot.tsx | 216 +++-- frontend/src/components/TopMenuBar.tsx | 11 +- frontend/src/components/TrendSearchPage.tsx | 151 ++++ .../trendSearch/TrendSearchCardChart.tsx | 187 ++++ .../TrendSearchCardSignalPanel.tsx | 70 ++ .../trendSearch/TrendSearchChartPanel.tsx | 192 +++++ .../trendSearch/TrendSearchFilterPanel.tsx | 183 ++++ .../trendSearch/TrendSearchResultCard.tsx | 132 +++ .../TrendSearchResultsCardGrid.tsx | 81 ++ .../trendSearch/TrendSearchResultsGrid.tsx | 111 +++ .../trendSearch/TrendSearchSignalPanel.tsx | 80 ++ .../TrendSearchViewHeaderControls.tsx | 55 ++ frontend/src/styles/trendSearchDashboard.css | 805 ++++++++++++++++++ frontend/src/utils/ChartManager.ts | 17 +- frontend/src/utils/backendApi.ts | 109 +++ frontend/src/utils/dataGenerator.ts | 9 + frontend/src/utils/permissions.ts | 5 +- frontend/src/utils/trendSearchApi.ts | 20 + frontend/src/utils/trendSearchConditions.ts | 240 ++++++ frontend/src/utils/trendSearchMetrics.ts | 44 + 29 files changed, 3753 insertions(+), 71 deletions(-) create mode 100644 backend/src/main/java/com/goldenchart/controller/TrendSearchController.java create mode 100644 backend/src/main/java/com/goldenchart/dto/TrendSearchConditionDto.java create mode 100644 backend/src/main/java/com/goldenchart/dto/TrendSearchRequest.java create mode 100644 backend/src/main/java/com/goldenchart/dto/TrendSearchResultDto.java create mode 100644 backend/src/main/java/com/goldenchart/service/TrendSearchService.java create mode 100644 backend/src/main/resources/db/migration/V29__trend_search_menu.sql create mode 100644 frontend/src/components/TrendSearchPage.tsx create mode 100644 frontend/src/components/trendSearch/TrendSearchCardChart.tsx create mode 100644 frontend/src/components/trendSearch/TrendSearchCardSignalPanel.tsx create mode 100644 frontend/src/components/trendSearch/TrendSearchChartPanel.tsx create mode 100644 frontend/src/components/trendSearch/TrendSearchFilterPanel.tsx create mode 100644 frontend/src/components/trendSearch/TrendSearchResultCard.tsx create mode 100644 frontend/src/components/trendSearch/TrendSearchResultsCardGrid.tsx create mode 100644 frontend/src/components/trendSearch/TrendSearchResultsGrid.tsx create mode 100644 frontend/src/components/trendSearch/TrendSearchSignalPanel.tsx create mode 100644 frontend/src/components/trendSearch/TrendSearchViewHeaderControls.tsx create mode 100644 frontend/src/styles/trendSearchDashboard.css create mode 100644 frontend/src/utils/trendSearchApi.ts create mode 100644 frontend/src/utils/trendSearchConditions.ts create mode 100644 frontend/src/utils/trendSearchMetrics.ts diff --git a/backend/src/main/java/com/goldenchart/auth/MenuIds.java b/backend/src/main/java/com/goldenchart/auth/MenuIds.java index e4de05f..25674c3 100644 --- a/backend/src/main/java/com/goldenchart/auth/MenuIds.java +++ b/backend/src/main/java/com/goldenchart/auth/MenuIds.java @@ -7,7 +7,7 @@ public final class MenuIds { private MenuIds() {} public static final List ALL = List.of( - "dashboard", "chart", "paper", "virtual", "strategy", "strategy-editor", "backtest", "notifications", "settings", + "dashboard", "chart", "paper", "virtual", "trend-search", "strategy", "strategy-editor", "backtest", "notifications", "settings", "settings_general", "settings_chart", "settings_indicators", "settings_backtest", "settings_strategy", "settings_paper", "settings_alert", "settings_network", "settings_admin" ); diff --git a/backend/src/main/java/com/goldenchart/controller/TrendSearchController.java b/backend/src/main/java/com/goldenchart/controller/TrendSearchController.java new file mode 100644 index 0000000..057c13d --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/TrendSearchController.java @@ -0,0 +1,41 @@ +package com.goldenchart.controller; + +import com.goldenchart.dto.TrendSearchRequest; +import com.goldenchart.dto.TrendSearchResultDto; +import com.goldenchart.service.TrendSearchService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 암호화폐 추세검색 API. + * + * POST /api/trend-search/scan — 조건 스캔 + * GET /api/trend-search/detail?market=KRW-BTC — 단일 종목 상세 + */ +@RestController +@RequestMapping("/trend-search") +@RequiredArgsConstructor +@Slf4j +public class TrendSearchController { + + private final TrendSearchService trendSearchService; + + @PostMapping("/scan") + public ResponseEntity> scan(@RequestBody TrendSearchRequest request) { + log.info("[TrendSearch] scan tf={} limit={}", request.getTimeframe(), request.getLimit()); + return ResponseEntity.ok(trendSearchService.scan(request)); + } + + @GetMapping("/detail") + public ResponseEntity detail( + @RequestParam String market, + @RequestParam(required = false) String timeframe) { + TrendSearchRequest req = new TrendSearchRequest(); + if (timeframe != null) req.setTimeframe(timeframe); + return ResponseEntity.ok(trendSearchService.detail(market, req)); + } +} diff --git a/backend/src/main/java/com/goldenchart/dto/TrendSearchConditionDto.java b/backend/src/main/java/com/goldenchart/dto/TrendSearchConditionDto.java new file mode 100644 index 0000000..cf6148d --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/TrendSearchConditionDto.java @@ -0,0 +1,21 @@ +package com.goldenchart.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TrendSearchConditionDto { + private String id; + private String category; + private String label; + private Double currentValue; + private String thresholdLabel; + private int progress; + /** match | pending | mismatch */ + private String status; +} diff --git a/backend/src/main/java/com/goldenchart/dto/TrendSearchRequest.java b/backend/src/main/java/com/goldenchart/dto/TrendSearchRequest.java new file mode 100644 index 0000000..a6abf7f --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/TrendSearchRequest.java @@ -0,0 +1,60 @@ +package com.goldenchart.dto; + +import lombok.Data; + +/** + * 추세검색 스캔 요청 — 좌측 필터 패널 (20개 조건). + */ +@Data +public class TrendSearchRequest { + + /** 캔들 타입 (1d 권장) */ + private String timeframe = "1d"; + + /** 결과 목록 최대 개수 */ + private int limit = 20; + + /** 스캔 대상 종목 수 (거래대금 상위) */ + private int scanLimit = 80; + + /** 주 정렬 기준 (조건 id) */ + private String sortBy = "near52wHigh"; + + /** 신고가 달성 횟수 산출 기간 (거래일) */ + private int newHighDays = 20; + + // ── 카테고리 ON/OFF ── + private boolean priceEnabled = true; + private boolean volumeEnabled = true; + private boolean flowEnabled = true; + private boolean fundamentalEnabled = false; + + // Ⅰ. 가격 및 탄력성 + private boolean near52wHigh = true; + private boolean ret3m = false; + private boolean ret6m = false; + private boolean maDeviation = true; + private boolean maFullAlign = true; + private boolean stage2 = false; + private boolean relativeStrength = true; + private boolean newHighCount = false; + + // Ⅱ. 거래량 및 대금 + private boolean tradeAmount = false; + private boolean volVs5dAvg = true; + private boolean turnover = false; + private boolean bidAskRatio = false; + + // Ⅲ. 메이저 수급 및 심리 + private boolean smartMoney = false; + private boolean instConsecutive = false; + private boolean rsiHigh = true; + private boolean macdPeak = false; + private boolean lightCredit = false; + + // Ⅳ. 펀더멘털 (주식 전용 — 코인 미지원) + private boolean earningsGrowth = false; + private boolean roe = false; + private boolean opMargin = false; + private boolean pegBelow1 = false; +} diff --git a/backend/src/main/java/com/goldenchart/dto/TrendSearchResultDto.java b/backend/src/main/java/com/goldenchart/dto/TrendSearchResultDto.java new file mode 100644 index 0000000..b5199c7 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/TrendSearchResultDto.java @@ -0,0 +1,24 @@ +package com.goldenchart.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TrendSearchResultDto { + private String market; + private String koreanName; + private double currentPrice; + private double changeRate; + private int matchRate; + private int matchedCount; + private int totalConditions; + private boolean highMatch; + private List conditions; +} diff --git a/backend/src/main/java/com/goldenchart/service/TrendSearchService.java b/backend/src/main/java/com/goldenchart/service/TrendSearchService.java new file mode 100644 index 0000000..9b12265 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/TrendSearchService.java @@ -0,0 +1,704 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.dto.*; +import com.goldenchart.storage.Ta4jStorage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.ta4j.core.*; +import org.ta4j.core.indicators.*; +import org.ta4j.core.indicators.averages.EMAIndicator; +import org.ta4j.core.indicators.averages.SMAIndicator; +import org.ta4j.core.indicators.helpers.*; +import org.ta4j.core.indicators.volume.OnBalanceVolumeIndicator; +import org.ta4j.core.bars.TimeBarBuilderFactory; +import org.ta4j.core.num.DoubleNumFactory; + +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 암호화폐 추세검색 — 업비트 KRW 마켓 20개 조건 스캔. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class TrendSearchService { + + private final HistoricalDataService historicalDataService; + private final WebClient.Builder webClientBuilder; + private final ObjectMapper objectMapper; + + @Value("${upbit.api.base-url:https://api.upbit.com}") + private String upbitBaseUrl; + + private static final int MIN_BARS = 60; + private static final int HIGH_MATCH_THRESHOLD = 90; + private static final int HISTORY_BARS = 365; + + public List scan(TrendSearchRequest req) { + TrendSearchRequest r = req != null ? req : new TrendSearchRequest(); + int limit = Math.min(Math.max(r.getLimit(), 1), 100); + int scanLimit = Math.min(Math.max(r.getScanLimit(), 10), 200); + String tf = normalizeTimeframe(r.getTimeframe()); + int barCount = "1d".equals(tf) || "1w".equals(tf) ? HISTORY_BARS : 200; + + List markets = fetchKrwMarketsByVolume(scanLimit); + if (markets.isEmpty()) return List.of(); + + Map tickers = fetchTickers(markets); + Map bidAskRatios = r.isBidAskRatio() ? fetchBidAskRatios(markets) : Map.of(); + + List btcCandles = List.of(); + if (r.isRelativeStrength()) { + try { + btcCandles = historicalDataService.getHistory("KRW-BTC", tf, null, barCount); + } catch (Exception e) { + log.debug("[TrendSearch] BTC benchmark skip: {}", e.getMessage()); + } + } + double btcRet3m = returnPct(btcCandles, 84); + double btcRet6m = returnPct(btcCandles, 180); + + List scored = new ArrayList<>(); + for (String market : markets) { + try { + List candles = historicalDataService.getHistory(market, tf, null, barCount); + if (candles.size() < MIN_BARS) continue; + + BarSeries series = toSeries(candles, tf); + TickerSnap tick = tickers.getOrDefault(market, TickerSnap.empty(market)); + MetricValues m = computeMetrics(series, candles, tick, bidAskRatios.get(market), + btcRet3m, btcRet6m, r.getNewHighDays()); + + if (!passesFilters(m, r)) continue; + + scored.add(new ScoredMarket(market, tick, candles, m)); + } catch (Exception e) { + log.debug("[TrendSearch] skip {}: {}", market, e.getMessage()); + } + } + + if (scored.isEmpty()) return List.of(); + + Map sortScores = buildSortScores(scored, r); + String sortKey = resolveSortKey(r); + Comparator cmp = Comparator + .comparingDouble((ScoredMarket s) -> sortScores.getOrDefault(s.market, 0.0)).reversed() + .thenComparingDouble(s -> s.metrics.matchScore).reversed(); + + List results = scored.stream() + .sorted(cmp) + .limit(limit) + .map(s -> toResult(s, r, sortScores)) + .collect(Collectors.toList()); + + return results; + } + + public TrendSearchResultDto detail(String market, TrendSearchRequest req) { + TrendSearchRequest r = req != null ? req : new TrendSearchRequest(); + String tf = normalizeTimeframe(r.getTimeframe()); + int barCount = "1d".equals(tf) || "1w".equals(tf) ? HISTORY_BARS : 200; + List candles = historicalDataService.getHistory(market, tf, null, barCount); + if (candles.isEmpty()) { + return TrendSearchResultDto.builder() + .market(market).matchRate(0).conditions(List.of()).build(); + } + + Map tickers = fetchTickers(List.of(market)); + TickerSnap tick = tickers.getOrDefault(market, TickerSnap.empty(market)); + Map bidAsk = r.isBidAskRatio() ? fetchBidAskRatios(List.of(market)) : Map.of(); + + double btcRet3m = 0, btcRet6m = 0; + if (r.isRelativeStrength()) { + List btc = historicalDataService.getHistory("KRW-BTC", tf, null, barCount); + btcRet3m = returnPct(btc, 84); + btcRet6m = returnPct(btc, 180); + } + + BarSeries series = toSeries(candles, tf); + MetricValues m = computeMetrics(series, candles, tick, bidAsk.get(market), + btcRet3m, btcRet6m, r.getNewHighDays()); + ScoredMarket sm = new ScoredMarket(market, tick, candles, m); + Map sortScores = Map.of(resolveSortKey(r), m.getSortValue(resolveSortKey(r))); + return toResult(sm, r, sortScores); + } + + // ── Metrics ───────────────────────────────────────────────────────────── + + private MetricValues computeMetrics(BarSeries series, List candles, TickerSnap tick, + Double bidAskRatio, double btcRet3m, double btcRet6m, int newHighDays) { + int end = series.getEndIndex(); + ClosePriceIndicator close = new ClosePriceIndicator(series); + VolumeIndicator vol = new VolumeIndicator(series); + double price = close.getValue(end).doubleValue(); + + double high52w = maxHigh(series, end, Math.min(252, end + 1)); + double near52wHigh = high52w > 0 ? (price / high52w) * 100 : 0; + + double ret3m = returnPct(candles, 84); + double ret6m = returnPct(candles, 180); + double rs = ret3m - btcRet3m; + + SMAIndicator ma5 = new SMAIndicator(close, 5); + SMAIndicator ma20 = new SMAIndicator(close, 20); + SMAIndicator ma60 = new SMAIndicator(close, 60); + SMAIndicator ma120 = new SMAIndicator(close, 120); + SMAIndicator ma150 = new SMAIndicator(close, 150); + SMAIndicator ma200 = new SMAIndicator(close, 200); + + double ma20v = safeVal(ma20, end); + double ma60v = safeVal(ma60, end); + double dev20 = ma20v > 0 ? ((price - ma20v) / ma20v) * 100 : 0; + double dev60 = ma60v > 0 ? ((price - ma60v) / ma60v) * 100 : 0; + double maDeviation = Math.max(dev20, dev60); + + boolean maFullAlign = end >= 200 + && price > safeVal(ma5, end) + && safeVal(ma5, end) > safeVal(ma20, end) + && safeVal(ma20, end) > safeVal(ma60, end) + && safeVal(ma60, end) > safeVal(ma120, end) + && safeVal(ma120, end) > safeVal(ma200, end); + + double ma200now = safeVal(ma200, end); + double ma200prev = end >= 220 ? safeVal(ma200, end - 20) : ma200now; + boolean stage2 = end >= 200 + && price > safeVal(ma150, end) + && price > ma200now + && ma200now > ma200prev; + + int nhDays = Math.max(5, Math.min(newHighDays, 60)); + int newHighCount = countNewHighs(series, end, nhDays); + + double tradeAmount = tick.accTradePrice24h > 0 ? tick.accTradePrice24h : tradeAmountFromBar(candles); + double volVs5dAvg = volVs5dAvg(candles); + double turnover = turnoverRate(vol, end); + + OnBalanceVolumeIndicator obv = new OnBalanceVolumeIndicator(series); + double smartMoney = end >= 20 + ? obv.getValue(end).doubleValue() - obv.getValue(end - 20).doubleValue() + : 0; + int instConsecutive = countInstConsecutive(candles); + + RSIIndicator rsi = new RSIIndicator(close, 14); + double rsiVal = end >= 15 ? rsi.getValue(end).doubleValue() : 0; + + MACDIndicator macd = new MACDIndicator(close, 12, 26); + double macdHist = end >= 35 ? macd.getValue(end).doubleValue() : 0; + double macdPeak = macdPeakScore(macd, end); + + boolean lightCredit = lightCreditPass(candles, vol, end); + + return new MetricValues( + near52wHigh, ret3m, ret6m, maDeviation, maFullAlign, stage2, rs, newHighCount, + tradeAmount, volVs5dAvg, turnover, bidAskRatio != null ? bidAskRatio : 0, + smartMoney, instConsecutive, rsiVal, macdPeak, macdHist, lightCredit + ); + } + + private boolean passesFilters(MetricValues m, TrendSearchRequest r) { + if (r.isMaFullAlign() && r.isPriceEnabled() && !m.maFullAlign) return false; + if (r.isStage2() && r.isPriceEnabled() && !m.stage2) return false; + if (r.isLightCredit() && r.isFlowEnabled() && !m.lightCredit) return false; + return true; + } + + private Map buildSortScores(List scored, TrendSearchRequest r) { + Map> buckets = new HashMap<>(); + for (ScoredMarket s : scored) { + for (String id : enabledSortIds(r)) { + buckets.computeIfAbsent(id, k -> new ArrayList<>()).add(s.metrics.getSortValue(id)); + } + } + Map out = new HashMap<>(); + for (ScoredMarket s : scored) { + double sum = 0; + int cnt = 0; + for (String id : enabledSortIds(r)) { + double pct = percentile(buckets.get(id), s.metrics.getSortValue(id)); + out.put(s.market + ":" + id, pct); + sum += pct; + cnt++; + } + s.metrics.matchScore = cnt > 0 ? sum / cnt : 0; + } + String sortKey = resolveSortKey(r); + for (ScoredMarket s : scored) { + out.put(s.market, s.metrics.getSortValue(sortKey)); + } + return out; + } + + private List enabledSortIds(TrendSearchRequest r) { + List ids = new ArrayList<>(); + if (r.isPriceEnabled()) { + if (r.isNear52wHigh()) ids.add("near52wHigh"); + if (r.isRet3m()) ids.add("ret3m"); + if (r.isRet6m()) ids.add("ret6m"); + if (r.isMaDeviation()) ids.add("maDeviation"); + if (r.isRelativeStrength()) ids.add("relativeStrength"); + if (r.isNewHighCount()) ids.add("newHighCount"); + } + if (r.isVolumeEnabled()) { + if (r.isTradeAmount()) ids.add("tradeAmount"); + if (r.isVolVs5dAvg()) ids.add("volVs5dAvg"); + if (r.isTurnover()) ids.add("turnover"); + if (r.isBidAskRatio()) ids.add("bidAskRatio"); + } + if (r.isFlowEnabled()) { + if (r.isSmartMoney()) ids.add("smartMoney"); + if (r.isInstConsecutive()) ids.add("instConsecutive"); + if (r.isRsiHigh()) ids.add("rsiHigh"); + if (r.isMacdPeak()) ids.add("macdPeak"); + } + return ids; + } + + private String resolveSortKey(TrendSearchRequest r) { + String key = r.getSortBy() != null ? r.getSortBy() : "near52wHigh"; + if (enabledSortIds(r).contains(key)) return key; + List enabled = enabledSortIds(r); + return enabled.isEmpty() ? "near52wHigh" : enabled.get(0); + } + + private TrendSearchResultDto toResult(ScoredMarket s, TrendSearchRequest r, Map sortScores) { + List conditions = buildConditions(s.metrics, r, sortScores, s.market); + int matched = (int) conditions.stream().filter(c -> "match".equals(c.getStatus())).count(); + int total = conditions.size(); + int matchRate = total > 0 ? Math.round(matched * 100f / total) : 0; + double lastClose = s.candles.get(s.candles.size() - 1).getClose(); + + return TrendSearchResultDto.builder() + .market(s.market) + .koreanName(s.tick.koreanName) + .currentPrice(s.tick.tradePrice > 0 ? s.tick.tradePrice : lastClose) + .changeRate(s.tick.changeRate) + .matchRate(matchRate) + .matchedCount(matched) + .totalConditions(total) + .highMatch(matchRate >= HIGH_MATCH_THRESHOLD) + .conditions(conditions) + .build(); + } + + private List buildConditions(MetricValues m, TrendSearchRequest r, + Map sortScores, String market) { + List out = new ArrayList<>(); + + if (r.isPriceEnabled()) { + addSort(out, "near52wHigh", "Ⅰ. 가격·탄력성", "52주 신고가 근접률", r.isNear52wHigh(), + m.near52wHigh, "≥ 90%", pctStatus(sortScores, market, "near52wHigh", 90)); + addSort(out, "ret3m", "Ⅰ. 가격·탄력성", "3개월 수익률", r.isRet3m(), + m.ret3m, "> 0%", pctStatus(sortScores, market, "ret3m", 70)); + addSort(out, "ret6m", "Ⅰ. 가격·탄력성", "6개월 수익률", r.isRet6m(), + m.ret6m, "> 0%", pctStatus(sortScores, market, "ret6m", 70)); + addSort(out, "maDeviation", "Ⅰ. 가격·탄력성", "MA 이격도(20·60)", r.isMaDeviation(), + m.maDeviation, "> 5%", pctStatus(sortScores, market, "maDeviation", 70)); + addFilter(out, "maFullAlign", "Ⅰ. 가격·탄력성", "MA 완전 정배열", r.isMaFullAlign(), m.maFullAlign); + addFilter(out, "stage2", "Ⅰ. 가격·탄력성", "Stage 2 추세", r.isStage2(), m.stage2); + addSort(out, "relativeStrength", "Ⅰ. 가격·탄력성", "RS vs BTC", r.isRelativeStrength(), + m.relativeStrength, "> 0", pctStatus(sortScores, market, "relativeStrength", 70)); + addSort(out, "newHighCount", "Ⅰ. 가격·탄력성", "신고가 달성 횟수", r.isNewHighCount(), + m.newHighCount, "≥ 3회", pctStatus(sortScores, market, "newHighCount", 70)); + } + if (r.isVolumeEnabled()) { + addSort(out, "tradeAmount", "Ⅱ. 거래량·대금", "당일 거래대금", r.isTradeAmount(), + m.tradeAmount, "상위", pctStatus(sortScores, market, "tradeAmount", 70)); + addSort(out, "volVs5dAvg", "Ⅱ. 거래량·대금", "5일 대비 거래대금↑", r.isVolVs5dAvg(), + m.volVs5dAvg, "> 120%", pctStatus(sortScores, market, "volVs5dAvg", 70)); + addSort(out, "turnover", "Ⅱ. 거래량·대금", "거래량 회전율", r.isTurnover(), + m.turnover, "> 100%", pctStatus(sortScores, market, "turnover", 70)); + addSort(out, "bidAskRatio", "Ⅱ. 거래량·대금", "매수잔량 비율", r.isBidAskRatio(), + m.bidAskRatio, "> 1.0", pctStatus(sortScores, market, "bidAskRatio", 70)); + } + if (r.isFlowEnabled()) { + addSort(out, "smartMoney", "Ⅲ. 수급·심리", "스마트머니(OBV)", r.isSmartMoney(), + m.smartMoney, "누적↑", pctStatus(sortScores, market, "smartMoney", 70)); + addSort(out, "instConsecutive", "Ⅲ. 수급·심리", "연속 순매수 일수", r.isInstConsecutive(), + m.instConsecutive, "≥ 3일", pctStatus(sortScores, market, "instConsecutive", 70)); + addSort(out, "rsiHigh", "Ⅲ. 수급·심리", "RSI(14)", r.isRsiHigh(), + m.rsiHigh, "≥ 70", m.rsiHigh >= 70 ? "match" : m.rsiHigh >= 55 ? "pending" : "mismatch"); + addSort(out, "macdPeak", "Ⅲ. 수급·심리", "MACD 최고치", r.isMacdPeak(), + m.macdPeak, "신고가", pctStatus(sortScores, market, "macdPeak", 70)); + addFilter(out, "lightCredit", "Ⅲ. 수급·심리", "거래량↓+주가↑", r.isLightCredit(), m.lightCredit); + } + if (r.isFundamentalEnabled()) { + addUnsupported(out, "earningsGrowth", "Ⅳ. 펀더멘털", "영업이익 성장(YoY)", r.isEarningsGrowth()); + addUnsupported(out, "roe", "Ⅳ. 펀더멘털", "ROE", r.isRoe()); + addUnsupported(out, "opMargin", "Ⅳ. 펀더멘털", "영업이익률", r.isOpMargin()); + addUnsupported(out, "pegBelow1", "Ⅳ. 펀더멘털", "PEG ≤ 1.0", r.isPegBelow1()); + } + return out; + } + + private void addSort(List out, String id, String cat, String label, + boolean enabled, double value, String threshold, String status) { + if (!enabled) return; + int progress = status.equals("match") ? 100 : status.equals("pending") ? 65 : 35; + out.add(cond(id, cat, label, value, threshold, progress, status)); + } + + private void addFilter(List out, String id, String cat, String label, + boolean enabled, boolean pass) { + if (!enabled) return; + out.add(cond(id, cat, label, pass ? 1.0 : 0.0, "필수 충족", pass ? 100 : 0, pass ? "match" : "mismatch")); + } + + private void addUnsupported(List out, String id, String cat, String label, boolean enabled) { + if (!enabled) return; + out.add(cond(id, cat, label, null, "코인 미지원", 0, "mismatch")); + } + + private String pctStatus(Map sortScores, String market, String id, double threshold) { + Double pct = sortScores.get(market + ":" + id); + if (pct != null) { + if (pct >= threshold) return "match"; + if (pct >= threshold * 0.85) return "pending"; + return "mismatch"; + } + return "pending"; + } + + private static double tradeAmountFromBar(List candles) { + if (candles.isEmpty()) return 0; + CandleBarDto c = candles.get(candles.size() - 1); + return c.getClose() * c.getVolume(); + } + + // ── Indicator helpers ─────────────────────────────────────────────────── + + private static double returnPct(List candles, int barsBack) { + if (candles.size() < 2) return 0; + int end = candles.size() - 1; + int start = Math.max(0, end - barsBack); + double prev = candles.get(start).getClose(); + double cur = candles.get(end).getClose(); + return prev > 0 ? ((cur - prev) / prev) * 100 : 0; + } + + private static double maxHigh(BarSeries series, int end, int lookback) { + int start = Math.max(0, end - lookback + 1); + double max = 0; + for (int i = start; i <= end; i++) { + max = Math.max(max, series.getBar(i).getHighPrice().doubleValue()); + } + return max; + } + + private static int countNewHighs(BarSeries series, int end, int days) { + int start = Math.max(0, end - days + 1); + int count = 0; + double runningMax = 0; + for (int i = 0; i < start; i++) { + runningMax = Math.max(runningMax, series.getBar(i).getHighPrice().doubleValue()); + } + for (int i = start; i <= end; i++) { + double h = series.getBar(i).getHighPrice().doubleValue(); + if (h >= runningMax) { + count++; + runningMax = h; + } + } + return count; + } + + private static double safeVal(SMAIndicator ma, int idx) { + if (idx < 0) return 0; + try { + return ma.getValue(idx).doubleValue(); + } catch (Exception e) { + return 0; + } + } + + private static double volVs5dAvg(List candles) { + if (candles.size() < 6) return 0; + int end = candles.size() - 1; + double today = candles.get(end).getClose() * candles.get(end).getVolume(); + double sum = 0; + for (int i = end - 5; i <= end - 1; i++) { + sum += candles.get(i).getClose() * candles.get(i).getVolume(); + } + double avg = sum / 5; + return avg > 0 ? (today / avg) * 100 : 0; + } + + private static double turnoverRate(VolumeIndicator vol, int end) { + if (end < 20) return 0; + double sum = 0; + for (int i = end - 19; i <= end - 1; i++) sum += vol.getValue(i).doubleValue(); + double avg = sum / 20; + double cur = vol.getValue(end).doubleValue(); + return avg > 0 ? (cur / avg) * 100 : 0; + } + + private static int countInstConsecutive(List candles) { + if (candles.size() < 3) return 0; + int streak = 0; + int max = 0; + for (int i = candles.size() - 1; i >= 1; i--) { + CandleBarDto c = candles.get(i); + CandleBarDto p = candles.get(i - 1); + boolean up = c.getClose() >= c.getOpen(); + boolean volUp = c.getVolume() >= p.getVolume(); + if (up && volUp) { + streak++; + max = Math.max(max, streak); + } else { + break; + } + } + return max; + } + + private static double macdPeakScore(MACDIndicator macd, int end) { + if (end < 35) return 0; + double cur = macd.getValue(end).doubleValue(); + double peak = Double.NEGATIVE_INFINITY; + for (int i = Math.max(0, end - 19); i <= end; i++) { + peak = Math.max(peak, macd.getValue(i).doubleValue()); + } + return peak != 0 ? (cur / peak) * 100 : 0; + } + + private static boolean lightCreditPass(List candles, VolumeIndicator vol, int end) { + if (candles.size() < 10 || end < 9) return false; + double volRecent = 0, volPrev = 0; + for (int i = end - 4; i <= end; i++) volRecent += vol.getValue(i).doubleValue(); + for (int i = end - 9; i <= end - 5; i++) volPrev += vol.getValue(i).doubleValue(); + volRecent /= 5; + volPrev /= 5; + double priceNow = candles.get(end).getClose(); + double pricePrev = candles.get(end - 5).getClose(); + return volPrev > 0 && volRecent < volPrev * 0.95 && priceNow > pricePrev; + } + + private static double percentile(List values, double v) { + if (values == null || values.isEmpty()) return 0; + long below = values.stream().filter(x -> x <= v).count(); + return (below * 100.0) / values.size(); + } + + private TrendSearchConditionDto cond(String id, String cat, String label, Double current, + String threshold, int progress, String status) { + return TrendSearchConditionDto.builder() + .id(id) + .category(cat) + .label(label) + .currentValue(current != null ? round2(current) : null) + .thresholdLabel(threshold) + .progress(Math.min(100, Math.max(0, progress))) + .status(status) + .build(); + } + + private double round2(double v) { + return Math.round(v * 100.0) / 100.0; + } + + // ── Upbit helpers ─────────────────────────────────────────────────────── + + private List fetchKrwMarketsByVolume(int limit) { + try { + WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build(); + String marketsJson = client.get() + .uri("/v1/market/all?isDetails=false") + .retrieve() + .bodyToMono(String.class) + .block(); + if (marketsJson == null) return List.of(); + + List> all = objectMapper.readValue(marketsJson, new TypeReference<>() {}); + List krw = all.stream() + .map(m -> (String) m.get("market")) + .filter(m -> m != null && m.startsWith("KRW-")) + .collect(Collectors.toList()); + + if (krw.isEmpty()) return List.of(); + + Map tickers = fetchTickers(krw); + return krw.stream() + .sorted(Comparator.comparingDouble((String m) -> + tickers.getOrDefault(m, TickerSnap.empty(m)).accTradePrice24h).reversed()) + .limit(limit) + .collect(Collectors.toList()); + } catch (Exception e) { + log.warn("[TrendSearch] market fetch fail: {}", e.getMessage()); + return List.of("KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-ADA"); + } + } + + private Map fetchTickers(List markets) { + if (markets.isEmpty()) return Map.of(); + try { + WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build(); + Map map = new HashMap<>(); + for (int i = 0; i < markets.size(); i += 100) { + List batch = markets.subList(i, Math.min(i + 100, markets.size())); + String joined = String.join(",", batch); + String json = client.get() + .uri("/v1/ticker?markets=" + joined) + .retrieve() + .bodyToMono(String.class) + .block(); + if (json == null) continue; + JsonNode arr = objectMapper.readTree(json); + for (JsonNode n : arr) { + String market = n.path("market").asText(); + map.put(market, new TickerSnap( + market, + n.path("korean_name").asText(market), + n.path("trade_price").asDouble(0), + n.path("signed_change_rate").asDouble(0) * 100, + n.path("acc_trade_price_24h").asDouble(0) + )); + } + } + return map; + } catch (Exception e) { + log.warn("[TrendSearch] ticker fetch fail: {}", e.getMessage()); + return Map.of(); + } + } + + private Map fetchBidAskRatios(List markets) { + Map out = new HashMap<>(); + try { + WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build(); + for (int i = 0; i < markets.size(); i += 10) { + List batch = markets.subList(i, Math.min(i + 10, markets.size())); + String joined = String.join(",", batch); + String json = client.get() + .uri("/v1/orderbook?markets=" + joined) + .retrieve() + .bodyToMono(String.class) + .block(); + if (json == null) continue; + JsonNode arr = objectMapper.readTree(json); + for (JsonNode ob : arr) { + String market = ob.path("market").asText(); + double bidSum = 0, askSum = 0; + for (JsonNode u : ob.path("orderbook_units")) { + bidSum += u.path("bid_size").asDouble(0); + askSum += u.path("ask_size").asDouble(0); + } + out.put(market, askSum > 0 ? bidSum / askSum : 0); + } + } + } catch (Exception e) { + log.warn("[TrendSearch] orderbook fetch fail: {}", e.getMessage()); + } + return out; + } + + private String normalizeTimeframe(String tf) { + if (tf == null || tf.isBlank()) return "1d"; + return switch (tf) { + case "1m", "3m", "5m", "10m", "15m", "30m", "1h", "4h", "1d", "1w", "1M" -> tf; + case "1D" -> "1d"; + case "15M", "15min" -> "15m"; + default -> "1d"; + }; + } + + private BarSeries toSeries(List candles, String tf) { + Duration duration = Ta4jStorage.parseDuration(tf); + BarSeries series = new BaseBarSeriesBuilder() + .withName("trend") + .withBarBuilderFactory(new TimeBarBuilderFactory()) + .withNumFactory(DoubleNumFactory.getInstance()) + .build(); + + for (CandleBarDto c : candles) { + try { + Instant endInstant = Instant.ofEpochSecond(c.getTime()).plus(duration); + Bar bar = series.barBuilder() + .timePeriod(duration) + .endTime(endInstant) + .openPrice(c.getOpen()) + .highPrice(c.getHigh()) + .lowPrice(c.getLow()) + .closePrice(c.getClose()) + .volume(c.getVolume()) + .build(); + series.addBar(bar, true); + } catch (Exception e) { + log.trace("[TrendSearch] Bar 추가 스킵: {}", e.getMessage()); + } + } + return series; + } + + private record TickerSnap(String market, String koreanName, double tradePrice, + double changeRate, double accTradePrice24h) { + static TickerSnap empty(String market) { + return new TickerSnap(market, market.replace("KRW-", ""), 0, 0, 0); + } + } + + private record ScoredMarket(String market, TickerSnap tick, List candles, MetricValues metrics) {} + + private static class MetricValues { + final double near52wHigh, ret3m, ret6m, maDeviation, relativeStrength; + final int newHighCount; + final double tradeAmount, volVs5dAvg, turnover, bidAskRatio; + final double smartMoney; + final int instConsecutive; + final double rsiHigh, macdPeak, macdHist; + final boolean maFullAlign, stage2, lightCredit; + double matchScore; + + MetricValues(double near52wHigh, double ret3m, double ret6m, double maDeviation, + boolean maFullAlign, boolean stage2, double relativeStrength, int newHighCount, + double tradeAmount, double volVs5dAvg, double turnover, double bidAskRatio, + double smartMoney, int instConsecutive, double rsiHigh, double macdPeak, + double macdHist, boolean lightCredit) { + this.near52wHigh = near52wHigh; + this.ret3m = ret3m; + this.ret6m = ret6m; + this.maDeviation = maDeviation; + this.maFullAlign = maFullAlign; + this.stage2 = stage2; + this.relativeStrength = relativeStrength; + this.newHighCount = newHighCount; + this.tradeAmount = tradeAmount; + this.volVs5dAvg = volVs5dAvg; + this.turnover = turnover; + this.bidAskRatio = bidAskRatio; + this.smartMoney = smartMoney; + this.instConsecutive = instConsecutive; + this.rsiHigh = rsiHigh; + this.macdPeak = macdPeak; + this.macdHist = macdHist; + this.lightCredit = lightCredit; + } + + double getSortValue(String id) { + return switch (id) { + case "near52wHigh" -> near52wHigh; + case "ret3m" -> ret3m; + case "ret6m" -> ret6m; + case "maDeviation" -> maDeviation; + case "relativeStrength" -> relativeStrength; + case "newHighCount" -> newHighCount; + case "tradeAmount" -> tradeAmount; + case "volVs5dAvg" -> volVs5dAvg; + case "turnover" -> turnover; + case "bidAskRatio" -> bidAskRatio; + case "smartMoney" -> smartMoney; + case "instConsecutive" -> instConsecutive; + case "rsiHigh" -> rsiHigh; + case "macdPeak" -> macdPeak; + default -> 0; + }; + } + } +} diff --git a/backend/src/main/resources/db/migration/V29__trend_search_menu.sql b/backend/src/main/resources/db/migration/V29__trend_search_menu.sql new file mode 100644 index 0000000..370276d --- /dev/null +++ b/backend/src/main/resources/db/migration/V29__trend_search_menu.sql @@ -0,0 +1,6 @@ +-- 추세검색 메뉴 권한 +INSERT INTO gc_role_menu_permission (role, menu_id, allowed) VALUES +('ADMIN', 'trend-search', 1), +('USER', 'trend-search', 1), +('GUEST', 'trend-search', 0) +ON DUPLICATE KEY UPDATE allowed = VALUES(allowed); diff --git a/frontend/src/App.css b/frontend/src/App.css index bc47e00..847f571 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -4110,24 +4110,27 @@ html.theme-blue { .multi-chart-grid { display: grid; flex: 1; - /* 높이를 명시해야 fr 행이 계산된다 */ height: 100%; min-height: 0; min-width: 0; - gap: 2px; - background: var(--border, #2a2e3a); + gap: 12px; + padding: 8px 10px 12px; + background: var(--bg, #131722); overflow: hidden; + align-content: stretch; } .multi-slot-wrap { position: relative; overflow: hidden; - background: var(--bg, #131722); + background: var(--panel, #1e222d); min-width: 0; min-height: 0; - /* grid stretch로 높이가 정해지므로 height 상속 허용 */ display: flex; flex-direction: column; + border: 1px solid var(--se-border, var(--border, #2a2e3a)); + border-radius: 12px; + transition: box-shadow 0.15s ease-out, border-color 0.15s ease-out; } /* 개별 차트 슬롯 */ @@ -4135,16 +4138,234 @@ html.theme-blue { display: flex; flex-direction: column; width: 100%; - /* 부모 multi-slot-wrap (flex col)이 flex:1을 주므로 이 쪽도 flex:1 */ flex: 1; min-height: 0; border: 2px solid transparent; - transition: border-color .15s; + transition: border-color .15s, box-shadow .15s; } .chart-slot.active { border-color: var(--accent, #2962ff); } +/* 멀티차트 컴팩트 — 가상투자 카드 차트 스타일 */ +.chart-slot--compact { + border: none; + border-radius: 0; + min-height: 360px; +} +.chart-slot--compact.active { + border: none; + box-shadow: inset 0 0 0 1px var(--accent, #2962ff); +} + +/* 멀티차트 추세색 — 가상투자 vtd-card--live / timing 과 동일하게 얇고 흐릿한 아웃라인 */ +.multi-slot-wrap:has(.chart-slot--trend-up:not(.chart-slot--receiving)), +.chart-slot--compact.chart-slot--trend-up:not(.chart-slot--receiving) { + border-color: color-mix(in srgb, var(--up, #ff6b6b) 35%, var(--se-border, var(--border, #2a2e3a))); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--up, #ff6b6b) 12%, transparent); +} + +.multi-slot-wrap:has(.chart-slot--trend-down:not(.chart-slot--receiving)), +.chart-slot--compact.chart-slot--trend-down:not(.chart-slot--receiving) { + border-color: color-mix(in srgb, var(--down, #4dabf7) 35%, var(--se-border, var(--border, #2a2e3a))); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--down, #4dabf7) 12%, transparent); +} + +.chart-slot--compact.chart-slot--trend-up:not(.chart-slot--receiving) { + background: linear-gradient( + 165deg, + color-mix(in srgb, var(--up, #ff6b6b) 10%, var(--panel, #1e222d)) 0%, + var(--panel, #1e222d) 55% + ); +} + +.chart-slot--compact.chart-slot--trend-down:not(.chart-slot--receiving) { + background: linear-gradient( + 165deg, + color-mix(in srgb, var(--down, #4dabf7) 10%, var(--panel, #1e222d)) 0%, + var(--panel, #1e222d) 55% + ); +} + +.chart-slot--compact.chart-slot--trend-up.active:not(.chart-slot--receiving) { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--up, #ff6b6b) 45%, var(--accent, #2962ff)); +} + +.chart-slot--compact.chart-slot--trend-down.active:not(.chart-slot--receiving) { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--down, #4dabf7) 45%, var(--accent, #2962ff)); +} + +.chart-slot--compact.chart-slot--trend-up:not(.chart-slot--receiving) .slot-header--compact { + border-bottom-color: color-mix(in srgb, var(--up, #ff6b6b) 18%, var(--se-border, var(--border, #2a2e3a))); +} + +.chart-slot--compact.chart-slot--trend-down:not(.chart-slot--receiving) .slot-header--compact { + border-bottom-color: color-mix(in srgb, var(--down, #4dabf7) 18%, var(--se-border, var(--border, #2a2e3a))); +} + +/* 실시간 수신 — 가상투자 vtd-card--receiving 와 동일 형광 연두 */ +.multi-slot-wrap:has(.chart-slot--receiving), +.chart-slot--compact.chart-slot--receiving { + border-color: #69f0ae !important; + box-shadow: + 0 0 0 1px #69f0ae, + 0 0 10px 4px #69f0ae66, + 0 0 22px 8px #b9f6ca33, + inset 0 0 48px color-mix(in srgb, #b9f6ca 14%, transparent); + transition: box-shadow 0.15s ease-out, border-color 0.15s ease-out; +} + +.chart-slot--compact.chart-slot--receiving { + background: linear-gradient( + 165deg, + color-mix(in srgb, #b9f6ca 14%, #131722) 0%, + color-mix(in srgb, #69f0ae 5%, var(--panel, #1e222d)) 40%, + var(--panel, #1e222d) 65% + ); +} + +.chart-slot--compact.chart-slot--receiving.active { + box-shadow: + 0 0 0 1px #69f0ae, + 0 0 10px 4px #69f0ae66, + 0 0 22px 8px #b9f6ca33, + inset 0 0 0 1px color-mix(in srgb, #69f0ae 55%, var(--accent, #2962ff)); +} + +.chart-slot--compact.chart-slot--receiving .slot-chart-wrap--compact { + box-shadow: inset 0 0 24px color-mix(in srgb, #b9f6ca 8%, transparent); +} + +.slot-header--compact { + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; + padding: 8px 10px; + min-height: unset; + border-bottom: 1px solid var(--border, #2a2e3a); +} + +.slot-compact-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; + width: 100%; + min-width: 0; +} + +.slot-compact-name { + flex: 0 1 auto; + max-width: 38%; + background: none; + border: none; + padding: 0; + cursor: pointer; + color: var(--text, #d1d4dc); + font-size: 13px; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; +} +.slot-compact-name.open { + color: var(--accent, #2962ff); +} + +.slot-compact-quote { + display: flex; + align-items: baseline; + gap: 6px; + flex: 0 1 auto; + min-width: 0; + white-space: nowrap; +} + +.slot-compact-price { + font-size: 13px; + font-weight: 700; +} + +.slot-compact-change { + font-size: 11px; + font-weight: 600; + opacity: 0.92; +} + +.slot-compact-tf { + display: inline-flex; + align-items: center; + gap: 4px; + flex: 0 0 auto; +} + +.slot-compact-tf-label { + font-size: 10px; + color: var(--text-muted, #787b86); + flex-shrink: 0; +} + +.slot-compact-tf-select { + appearance: none; + padding: 3px 22px 3px 8px; + border-radius: 6px; + border: 1px solid var(--border, #2a2e3a); + background: var(--bg, #131722) + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23787b86' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E") + no-repeat right 6px center; + color: var(--text, #d1d4dc); + font-size: 11px; + font-weight: 600; + cursor: pointer; + min-width: 52px; +} + +.slot-compact-tf-select:focus { + outline: none; + border-color: var(--accent, #2962ff); +} + +.slot-compact-status { + display: flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + margin-left: auto; +} + +@media (max-width: 420px) { + .slot-compact-row { + row-gap: 6px; + } + .slot-compact-name { + max-width: 55%; + } + .slot-compact-status { + width: 100%; + justify-content: flex-end; + } +} + +.chart-slot--compact .slot-chart-wrap--compact { + flex: 1; + min-height: 240px; + height: auto !important; + border: none !important; + border-radius: 0 !important; + border-top: none !important; +} + +.chart-slot--compact .slot-chart-wrap--compact .chart-hover-toolbar { + display: none !important; +} + +.chart-slot--compact .ws-badge { + font-size: 9px; + padding: 2px 6px; +} + /* 슬롯 미니 헤더 */ .slot-header { display: flex; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d624c01..9d5c89f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -70,6 +70,7 @@ import { BacktestHistoryPage } from './components/BacktestHistoryPage'; import SettingsPage from './components/SettingsPage'; import PaperTradingPage from './components/PaperTradingPage'; import VirtualTradingPage from './components/VirtualTradingPage'; +import TrendSearchPage from './components/TrendSearchPage'; import DashboardPage from './components/DashboardPage'; import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi'; import ChartLegendBar from './components/ChartLegendBar'; @@ -85,6 +86,7 @@ import { type BacktestSignal, } from './utils/backendApi'; import { useVirtualLiveStrategy } from './hooks/useVirtualLiveStrategy'; +import type { ChartRealtimeSource } from './hooks/useChartRealtimeData'; import { VIRTUAL_SESSION_CHANGED_EVENT } from './utils/virtualTradingStorage'; import { notifyPaperTradesChanged } from './utils/paperTradeEvents'; import { useIsMobile } from './hooks/useMediaQuery'; @@ -1628,6 +1630,15 @@ function App() { /> )} + {menuPage === 'trend-search' && ( + + )} + {/* ── 설정 화면 ──────────────────────────────────────────────────── */} {menuPage === 'notifications' && ( {/* 슬롯 1~7 */} @@ -2028,6 +2040,7 @@ function App() { chartVolumeVisible={chartVolumeVisible} chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} displayTimezone={displayTimezone} + compactMode /> ))} diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx index a0bbfe1..c77c046 100644 --- a/frontend/src/components/ChartSlot.tsx +++ b/frontend/src/components/ChartSlot.tsx @@ -20,6 +20,9 @@ import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from '../utils/sma import { normalizeIchimokuConfig } from '../utils/ichimokuConfig'; import { isUpbitMarket } from '../utils/upbitApi'; import { useChartRealtimeData, type WsStatus } from '../hooks/useChartRealtimeData'; +import { useLiveReceiveFlash } from '../hooks/useLiveReceiveFlash'; +import VirtualLiveBadge from './virtual/VirtualLiveBadge'; +import type { VirtualLiveStatus } from '../hooks/useVirtualTargetLiveStatus'; import { invalidateMarketCache } from '../utils/requestCache'; import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader'; import { useIndicatorSettings } from '../hooks/useIndicatorSettings'; @@ -39,6 +42,24 @@ import type { ChartManager } from '../utils/ChartManager'; // ── 타임프레임 옵션 ──────────────────────────────────────────────────────── const TF_OPTIONS: Timeframe[] = ['1m','3m','5m','10m','15m','30m','1h','4h','1D','1W','1M']; +function compactKoName(market: string): string { + const ko = getKoreanName(market); + if (ko && ko !== market) return ko; + return market.replace(/^KRW-/, ''); +} + +function wsToLiveStatus(status: WsStatus): VirtualLiveStatus { + switch (status) { + case 'connected': return 'live'; + case 'connecting': return 'connecting'; + case 'disconnected': + case 'error': + return 'disconnected'; + default: + return 'idle'; + } +} + // ── Ws 배지 ─────────────────────────────────────────────────────────────── const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => { if (!isUpbit) return null; @@ -139,6 +160,8 @@ export interface ChartSlotProps { chartVolumeVisible?: boolean; chartRealtimeSource?: 'BACKEND_STOMP' | 'UPBIT_DIRECT'; displayTimezone?: string; + /** 멀티차트 — 가상투자 카드 차트처럼 컴팩트 카드 UI */ + compactMode?: boolean; } const ChartSlot = forwardRef(function ChartSlot({ @@ -159,6 +182,7 @@ const ChartSlot = forwardRef(function ChartSlot chartVolumeVisible = true, chartRealtimeSource = 'BACKEND_STOMP', displayTimezone, + compactMode = false, }, ref) { // ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ──────────────────────── const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings(); @@ -489,6 +513,13 @@ const ChartSlot = forwardRef(function ChartSlot const dailyChange = currentBar ? currentBar.close - prevClose : 0; const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 0; + const receiveSignal = useMemo(() => { + if (!useUpbit || wsStatus !== 'connected' || !latestBar) return null; + return `${latestBar.time}|${latestBar.close}|${latestBar.volume}`; + }, [useUpbit, wsStatus, latestBar]); + + const receiving = useLiveReceiveFlash(receiveSignal, compactMode && useUpbit && wsStatus === 'connected'); + // ── 인디케이터 핸들러 ───────────────────────────────────────────────── const handleIndicatorSave = useCallback((updated: IndicatorConfig) => { setIndicators(prev => prev.map(i => i.id === updated.id ? updated : i)); @@ -564,36 +595,127 @@ const ChartSlot = forwardRef(function ChartSlot setShowMarket(false); }, []); + const displayKoName = compactKoName(symbol); + const trendUp = compactMode && currentBar != null && dailyChangePct >= 0; + const trendDown = compactMode && currentBar != null && dailyChangePct < 0; + return (
- {/* ── 슬롯 미니 헤더 ────────────────────────────────────────────────── */} -
e.stopPropagation()}> - {/* 심볼 버튼: 클릭 시 슬롯 영역 rect를 캡처 후 MarketSearchPanel Portal 오픈 */} - + {/* ── 슬롯 헤더 ──────────────────────────────────────────────────────── */} +
e.stopPropagation()} + > + {compactMode ? ( +
+ + + {currentBar && ( +
+ + {formatPrice(currentBar.close)} + + + {isUp ? '+' : '-'}{Math.abs(dailyChangePct).toFixed(2)}% + +
+ )} + + + +
+ {isLoading && 로딩…} + {useUpbit && ( + + )} +
+
+ ) : ( + <> + +
+ {TF_OPTIONS.map(tf => ( + + ))} +
+ {currentBar && ( + + {formatPrice(currentBar.close)} + + {isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}% + + + )} + {useUpbit && } + {isLoading && 로딩...} + + )} - {/* MarketSearchPanel: body 포털로 렌더 + anchorRect로 해당 슬롯 영역 안에 표시 */} {showMarket && ReactDOM.createPortal( (function ChartSlot />, document.body, )} - - {/* 타임프레임 선택 */} -
- {TF_OPTIONS.map(tf => ( - - ))} -
- - {/* 현재가 */} - {currentBar && ( - - {formatPrice(currentBar.close)} - - {isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}% - - - )} - - {useUpbit && } - {isLoading && 로딩...}
{/* ── TradingChart ─────────────────────────────────────────────────── */}
{useUpbit && isLoading && ( -
-
-
+ compactMode ? ( +
차트 로딩…
+ ) : ( +
+
+
+ ) )} {isLoadingMore && (
@@ -663,8 +763,10 @@ const ChartSlot = forwardRef(function ChartSlot drawingTool="cursor" drawings={drawings} logScale={false} - drawingsLocked={false} - drawingsVisible={true} + drawingsLocked={compactMode} + drawingsVisible={!compactMode} + showHoverToolbar={!compactMode} + volumeVisible={chartVolumeVisible} onCrosshair={data => { setLegend(data); if (data && onCrosshairTime) onCrosshairTime(data.time ?? 0); diff --git a/frontend/src/components/TopMenuBar.tsx b/frontend/src/components/TopMenuBar.tsx index 6cf93d9..4c177b6 100644 --- a/frontend/src/components/TopMenuBar.tsx +++ b/frontend/src/components/TopMenuBar.tsx @@ -11,7 +11,7 @@ import TradeAlertPopupMenubarSelect from './TradeAlertPopupMenubarSelect'; import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout'; import { canAccessMenu } from '../utils/permissions'; -export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'strategy' | 'strategy-editor' | 'backtest' | 'notifications' | 'settings'; +export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'strategy' | 'strategy-editor' | 'backtest' | 'notifications' | 'settings'; interface TopMenuBarProps { activePage: MenuPage; @@ -137,11 +137,20 @@ const IcVirtual = () => ( ); +const IcTrendSearch = () => ( + + + + + +); + const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [ { page: 'dashboard', label: '대시보드', icon: }, { page: 'chart', label: '실시간차트', icon: }, { page: 'paper', label: '모의투자', icon: }, { page: 'virtual', label: '가상투자', icon: }, + { page: 'trend-search', label: '추세검색', icon: }, { page: 'strategy-editor', label: '전략편집기', icon: }, { page: 'backtest', label: '백테스팅', icon: }, { page: 'settings', label: '설정', icon: }, diff --git a/frontend/src/components/TrendSearchPage.tsx b/frontend/src/components/TrendSearchPage.tsx new file mode 100644 index 0000000..785b5ad --- /dev/null +++ b/frontend/src/components/TrendSearchPage.tsx @@ -0,0 +1,151 @@ +/** + * GoldenChart 암호화폐 추세검색 (Trend Search) + */ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import BuilderPageShell from './layout/BuilderPageShell'; +import TrendSearchFilterPanel from './trendSearch/TrendSearchFilterPanel'; +import TrendSearchResultsCardGrid from './trendSearch/TrendSearchResultsCardGrid'; +import TrendSearchViewHeaderControls from './trendSearch/TrendSearchViewHeaderControls'; +import type { TrendSearchDisplayMode } from './trendSearch/TrendSearchResultCard'; +import type { Theme } from '../types'; +import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData'; +import type { TickerData } from '../hooks/useMarketTicker'; +import { + DEFAULT_TREND_SEARCH_REQUEST, + scanTrendSearch, + fetchTrendSearchDetail, + type TrendSearchRequest, + type TrendSearchResultDto, +} from '../utils/trendSearchApi'; +import '../styles/trendSearchDashboard.css'; +import '../styles/virtualTradingDashboard.css'; + +const REFRESH_MS = 3000; +const DISPLAY_MODE_KEY = 'tsd-display-mode'; + +function loadDisplayMode(): TrendSearchDisplayMode { + try { + const v = localStorage.getItem(DISPLAY_MODE_KEY); + if (v === 'chart' || v === 'summary') return v; + } catch { /* ignore */ } + return 'summary'; +} + +interface Props { + theme?: Theme; + chartRealtimeSource?: ChartRealtimeSource; + chartSeriesPriceLabels?: boolean; + tickers?: Map; +} + +const TrendSearchPage: React.FC = ({ + theme = 'dark', + chartRealtimeSource = 'BACKEND_STOMP', + chartSeriesPriceLabels = true, + tickers, +}) => { + const [filters, setFilters] = useState(() => ({ ...DEFAULT_TREND_SEARCH_REQUEST })); + const [results, setResults] = useState([]); + const [selectedMarket, setSelectedMarket] = useState(null); + const [searching, setSearching] = useState(false); + const [autoRefresh, setAutoRefresh] = useState(true); + const [displayMode, setDisplayMode] = useState(() => loadDisplayMode()); + const [flashMarkets, setFlashMarkets] = useState>(new Set()); + const [lastUpdatedAt, setLastUpdatedAt] = useState(Date.now()); + const filtersRef = useRef(filters); + filtersRef.current = filters; + + const runSearch = useCallback(async (opts?: { silent?: boolean }) => { + if (!opts?.silent) setSearching(true); + try { + const list = await scanTrendSearch(filtersRef.current); + setResults(list); + setLastUpdatedAt(Date.now()); + setSelectedMarket(prev => { + if (prev && list.some(r => r.market === prev)) return prev; + return list[0]?.market ?? null; + }); + if (list.length > 0) { + setFlashMarkets(new Set(list.slice(0, 5).map(r => r.market))); + setTimeout(() => setFlashMarkets(new Set()), 600); + } + } catch (e) { + console.warn('[TrendSearch]', e); + if (!opts?.silent) window.alert('추세검색 스캔에 실패했습니다. 백엔드 연결을 확인하세요.'); + } finally { + if (!opts?.silent) setSearching(false); + } + }, []); + + useEffect(() => { void runSearch(); }, []); + + useEffect(() => { + if (!autoRefresh) return; + const id = window.setInterval(() => void runSearch({ silent: true }), REFRESH_MS); + return () => clearInterval(id); + }, [autoRefresh, runSearch]); + + useEffect(() => { + try { localStorage.setItem(DISPLAY_MODE_KEY, displayMode); } catch { /* ignore */ } + }, [displayMode]); + + const handleSelect = useCallback(async (row: TrendSearchResultDto) => { + setSelectedMarket(row.market); + try { + const detail = await fetchTrendSearchDetail(row.market, filters.timeframe); + if (detail) { + setResults(prev => prev.map(r => (r.market === detail.market ? detail : r))); + } + } catch { /* keep row */ } + }, [filters.timeframe]); + + return ( + void runSearch()} + resultCount={results.length} + /> + )} + leftStorageKey="tsd-left-width" + leftDefaultWidth={320} + leftCollapsedStorageKey="tsd-left-open" + collapsiblePanels + left={( + void runSearch()} + searching={searching} + /> + )} + center={( + void handleSelect(row)} + flashMarkets={flashMarkets} + theme={theme} + chartRealtimeSource={chartRealtimeSource} + chartSeriesPriceLabels={chartSeriesPriceLabels} + tickers={tickers} + lastUpdatedAt={lastUpdatedAt} + /> + )} + /> + ); +}; + +export default TrendSearchPage; diff --git a/frontend/src/components/trendSearch/TrendSearchCardChart.tsx b/frontend/src/components/trendSearch/TrendSearchCardChart.tsx new file mode 100644 index 0000000..c983ed5 --- /dev/null +++ b/frontend/src/components/trendSearch/TrendSearchCardChart.tsx @@ -0,0 +1,187 @@ +/** + * 추세검색 카드 — 인라인 캔들 + RSI/MACD/BB + */ +import React, { + useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, +} from 'react'; +import TradingChart from '../TradingChart'; +import { pinCandleWatch } from '../../utils/backendApi'; +import type { + ChartMode, ChartType, Drawing, LegendData, OHLCVBar, Theme, Timeframe, +} from '../../types'; +import { isUpbitMarket } from '../../utils/upbitApi'; +import { useChartRealtimeData, type ChartRealtimeSource } from '../../hooks/useChartRealtimeData'; +import { useHistoryLoader } from '../../hooks/useHistoryLoader'; +import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; +import type { ChartManager } from '../../utils/ChartManager'; +import { timeframeToCandleType } from '../../utils/chartCandleType'; +import { enrichIndicatorConfig } from '../../utils/indicatorRegistry'; +import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone'; + +interface Props { + market: string; + timeframe: string; + theme?: Theme; + chartRealtimeSource?: ChartRealtimeSource; + chartSeriesPriceLabels?: boolean; +} + +const noop = () => {}; +const CHART_INDICATOR_TYPES = ['BollingerBands', 'RSI', 'MACD'] as const; + +function toChartTimeframe(tf: string): Timeframe { + if (tf === '1d') return '1D'; + if (tf === '1w') return '1W'; + if (tf === '1M') return '1M'; + return tf as Timeframe; +} + +const TrendSearchCardChart: React.FC = ({ + market, + timeframe, + theme = 'dark', + chartRealtimeSource = 'BACKEND_STOMP', + chartSeriesPriceLabels = true, +}) => { + const chartTf = toChartTimeframe(timeframe); + const { getParams, getVisualConfig } = useIndicatorSettings(); + + const indicators = useMemo( + () => CHART_INDICATOR_TYPES.map(type => { + const base = enrichIndicatorConfig({ + id: `${type}-tsd-card`, + type, + params: { ...getParams(type) }, + }); + return { + ...base, + plots: getVisualConfig(type)?.plots ?? base.plots, + }; + }), + [getParams, getVisualConfig], + ); + + const managerRef = useRef(null); + const canvasWrapRef = useRef(null); + const [chartReloadTick, setChartReloadTick] = useState(0); + const chartReloadTriggeredRef = useRef(false); + const pendingBarRef = useRef(null); + const barsMarketRef = useRef(null); + const chartLiveReadyRef = useRef(false); + const marketRef = useRef(market); + marketRef.current = market; + + const handleCandlesReady = useCallback(() => { + chartLiveReadyRef.current = true; + const pending = pendingBarRef.current; + const mgr = managerRef.current; + if (!pending || !mgr || barsMarketRef.current !== marketRef.current) return; + pendingBarRef.current = null; + mgr.updateBar(pending); + }, []); + + const applyRealtimeBar = useCallback((bar: OHLCVBar, append: boolean) => { + if (barsMarketRef.current !== marketRef.current) return; + if (!chartLiveReadyRef.current || !managerRef.current) { + pendingBarRef.current = bar; + return; + } + if (append) managerRef.current.appendBar(bar); + else managerRef.current.updateBar(bar); + }, []); + + const handleTickUpdate = useCallback((bar: OHLCVBar) => applyRealtimeBar(bar, false), [applyRealtimeBar]); + const handleNewCandle = useCallback((bar: OHLCVBar) => applyRealtimeBar(bar, true), [applyRealtimeBar]); + + const useUpbit = isUpbitMarket(market); + + const { bars, barsMarket, isLoading } = useChartRealtimeData( + market, + chartTf, + useMemo(() => ({ + onTickUpdate: handleTickUpdate, + onNewCandle: handleNewCandle, + enabled: useUpbit, + source: chartRealtimeSource, + }), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]), + ); + + const { isLoadingMore } = useHistoryLoader({ + symbol: market, + timeframe: chartTf, + isUpbit: useUpbit, + managerRef, + }); + + barsMarketRef.current = barsMarket; + + useLayoutEffect(() => { + chartLiveReadyRef.current = false; + pendingBarRef.current = null; + chartReloadTriggeredRef.current = false; + }, [market, chartTf]); + + useEffect(() => { + const timers = [100, 300, 800, 1500].map(delay => + setTimeout(() => { + const wrap = canvasWrapRef.current; + const mgr = managerRef.current; + if (!wrap) return; + const h = wrap.clientHeight; + if (h <= 0) return; + if (mgr?.hasMainSeries()) mgr.resetPaneHeights(h); + else if (!chartReloadTriggeredRef.current && delay >= 800 && bars.length > 0) { + chartReloadTriggeredRef.current = true; + setChartReloadTick(t => t + 1); + } + }, delay), + ); + return () => timers.forEach(clearTimeout); + }, [market, chartTf, bars.length]); + + useEffect(() => { + if (!useUpbit) return; + void pinCandleWatch(market, timeframeToCandleType(chartTf)); + }, [market, chartTf, useUpbit]); + + return ( +
+
+ {isLoading &&
차트 로딩…
} + {isLoadingMore && ( +
+
+ 과거 데이터… +
+ )} + void} + onManagerReady={mgr => { managerRef.current = mgr; }} + onAddDrawing={noop as (d: Drawing) => void} + onCandlesReady={handleCandlesReady} + magnifierEnabled={false} + volumeVisible + showHoverToolbar={false} + seriesPriceLabelsEnabled={chartSeriesPriceLabels} + /> +
+
+ ); +}; + +export default TrendSearchCardChart; diff --git a/frontend/src/components/trendSearch/TrendSearchCardSignalPanel.tsx b/frontend/src/components/trendSearch/TrendSearchCardSignalPanel.tsx new file mode 100644 index 0000000..ff106d6 --- /dev/null +++ b/frontend/src/components/trendSearch/TrendSearchCardSignalPanel.tsx @@ -0,0 +1,70 @@ +import React, { useMemo } from 'react'; +import type { TrendSearchConditionDto } from '../../utils/trendSearchApi'; +import { computeMatchRate, getTrafficLightState } from '../../utils/virtualSignalMetrics'; +import { buildTrendSearchMetrics } from '../../utils/trendSearchMetrics'; +import VirtualSignalEqualizer from '../virtual/VirtualSignalEqualizer'; +import VirtualConditionList from '../virtual/VirtualConditionList'; +import VirtualSignalTrafficLight from '../virtual/VirtualSignalTrafficLight'; +import VirtualIndicatorCompareTable from '../virtual/VirtualIndicatorCompareTable'; + +interface Props { + conditions: TrendSearchConditionDto[]; + matchRate: number; + matchedCount: number; + totalConditions: number; + updatedAt?: number; +} + +const TrendSearchCardSignalPanel: React.FC = ({ + conditions, + matchRate, + matchedCount, + totalConditions, + updatedAt, +}) => { + const metrics = useMemo(() => buildTrendSearchMetrics(conditions), [conditions]); + const resolvedMatch = useMemo( + () => computeMatchRate(metrics, matchRate), + [metrics, matchRate], + ); + const trafficState = useMemo( + () => getTrafficLightState(resolvedMatch, metrics), + [resolvedMatch, metrics], + ); + + if (conditions.length === 0) { + return

조건 데이터 없음

; + } + + const updatedLabel = updatedAt + ? new Date(updatedAt).toLocaleTimeString('ko-KR', { + hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, + }) + : '—'; + + return ( +
+
+
SIGNAL INTELLIGENCE & MATCH RATES
+
+
+ +
+ + +
+
+
+ +
+
+ 갱신 {updatedLabel} + + {matchedCount}/{totalConditions} 조건 · 일치율 {resolvedMatch}% + +
+
+ ); +}; + +export default TrendSearchCardSignalPanel; diff --git a/frontend/src/components/trendSearch/TrendSearchChartPanel.tsx b/frontend/src/components/trendSearch/TrendSearchChartPanel.tsx new file mode 100644 index 0000000..f62a25a --- /dev/null +++ b/frontend/src/components/trendSearch/TrendSearchChartPanel.tsx @@ -0,0 +1,192 @@ +/** + * 추세검색 — 우측 심층 차트 + */ +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import TradingChart from '../TradingChart'; +import type { ChartMode, ChartType, Drawing, LegendData, OHLCVBar, Theme, Timeframe, IndicatorConfig } from '../../types'; +import { pinCandleWatch } from '../../utils/backendApi'; +import { isUpbitMarket } from '../../utils/upbitApi'; +import { useChartRealtimeData, type ChartRealtimeSource } from '../../hooks/useChartRealtimeData'; +import { useHistoryLoader } from '../../hooks/useHistoryLoader'; +import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; +import type { ChartManager } from '../../utils/ChartManager'; +import { timeframeToCandleType } from '../../utils/chartCandleType'; +import { enrichIndicatorConfig } from '../../utils/indicatorRegistry'; +import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone'; + +interface Props { + market: string; + timeframe: string; + theme?: Theme; + chartRealtimeSource?: ChartRealtimeSource; + chartSeriesPriceLabels?: boolean; +} + +const noop = () => {}; + +function toChartTimeframe(tf: string): Timeframe { + if (tf === '1d') return '1D'; + if (tf === '1w') return '1W'; + if (tf === '1M') return '1M'; + return tf as Timeframe; +} + +const CHART_INDICATOR_TYPES = ['BollingerBands', 'RSI', 'MACD'] as const; + +const TrendSearchChartPanel: React.FC = ({ + market, + timeframe, + theme = 'dark', + chartRealtimeSource = 'BACKEND_STOMP', + chartSeriesPriceLabels = true, +}) => { + const chartTf = toChartTimeframe(timeframe); + const { getParams, getVisualConfig } = useIndicatorSettings(); + + const indicators = useMemo((): IndicatorConfig[] => + CHART_INDICATOR_TYPES.map(type => { + const base = enrichIndicatorConfig({ + id: `${type}-tsd`, + type, + params: { ...getParams(type) }, + }); + return { + ...base, + plots: getVisualConfig(type)?.plots ?? base.plots, + }; + }), + [getParams, getVisualConfig]); + + const managerRef = useRef(null); + const canvasWrapRef = useRef(null); + const [chartReloadTick, setChartReloadTick] = useState(0); + const chartReloadTriggeredRef = useRef(false); + const pendingBarRef = useRef(null); + const barsMarketRef = useRef(null); + const chartLiveReadyRef = useRef(false); + const marketRef = useRef(market); + marketRef.current = market; + + const handleCandlesReady = useCallback(() => { + chartLiveReadyRef.current = true; + const pending = pendingBarRef.current; + const mgr = managerRef.current; + if (!pending || !mgr || barsMarketRef.current !== marketRef.current) return; + pendingBarRef.current = null; + mgr.updateBar(pending); + }, []); + + const applyRealtimeBar = useCallback((bar: OHLCVBar, append: boolean) => { + if (barsMarketRef.current !== marketRef.current) return; + if (!chartLiveReadyRef.current || !managerRef.current) { + pendingBarRef.current = bar; + return; + } + if (append) managerRef.current.appendBar(bar); + else managerRef.current.updateBar(bar); + }, []); + + const handleTickUpdate = useCallback((bar: OHLCVBar) => applyRealtimeBar(bar, false), [applyRealtimeBar]); + const handleNewCandle = useCallback((bar: OHLCVBar) => applyRealtimeBar(bar, true), [applyRealtimeBar]); + + const useUpbit = isUpbitMarket(market); + + const { bars, barsMarket, isLoading } = useChartRealtimeData( + market, + chartTf, + useMemo(() => ({ + onTickUpdate: handleTickUpdate, + onNewCandle: handleNewCandle, + enabled: useUpbit, + source: chartRealtimeSource, + }), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]), + ); + + const { isLoadingMore } = useHistoryLoader({ + symbol: market, + timeframe: chartTf, + isUpbit: useUpbit, + managerRef, + }); + + barsMarketRef.current = barsMarket; + + useLayoutEffect(() => { + chartLiveReadyRef.current = false; + pendingBarRef.current = null; + chartReloadTriggeredRef.current = false; + }, [market, chartTf]); + + useEffect(() => { + const timers = [100, 300, 800, 1500].map(delay => + setTimeout(() => { + const wrap = canvasWrapRef.current; + const mgr = managerRef.current; + if (!wrap) return; + const h = wrap.clientHeight; + if (h <= 0) return; + if (mgr?.hasMainSeries()) mgr.resetPaneHeights(h); + else if (!chartReloadTriggeredRef.current && delay >= 800 && bars.length > 0) { + chartReloadTriggeredRef.current = true; + setChartReloadTick(t => t + 1); + } + }, delay), + ); + return () => timers.forEach(clearTimeout); + }, [market, chartTf, bars.length]); + + useEffect(() => { + if (!useUpbit) return; + void pinCandleWatch(market, timeframeToCandleType(chartTf)); + if (timeframeToCandleType(chartTf) !== '1m') void pinCandleWatch(market, '1m'); + }, [market, chartTf, useUpbit]); + + return ( +
+
+ 심층 차트 분석 영역 +
+ {['↖', '/', '⌇', '□', 'T', '📷'].map((icon, i) => ( + + ))} +
+
+
+ {isLoading &&
차트 로딩…
} + {isLoadingMore && ( +
+
+ 과거 데이터 로딩… +
+ )} + void} + onManagerReady={mgr => { managerRef.current = mgr; }} + onAddDrawing={noop as (d: Drawing) => void} + onCandlesReady={handleCandlesReady} + magnifierEnabled={false} + volumeVisible + showHoverToolbar={false} + seriesPriceLabelsEnabled={chartSeriesPriceLabels} + /> +
+
+ ); +}; + +export default TrendSearchChartPanel; diff --git a/frontend/src/components/trendSearch/TrendSearchFilterPanel.tsx b/frontend/src/components/trendSearch/TrendSearchFilterPanel.tsx new file mode 100644 index 0000000..beaa3a8 --- /dev/null +++ b/frontend/src/components/trendSearch/TrendSearchFilterPanel.tsx @@ -0,0 +1,183 @@ +import React, { useMemo } from 'react'; +import type { TrendSearchRequest } from '../../utils/trendSearchApi'; +import { TREND_TIMEFRAMES } from '../../utils/trendSearchApi'; +import { + TREND_CATEGORY_ORDER, + TREND_CONDITION_DEFS, + TREND_SORT_CONDITION_IDS, + type TrendConditionMode, +} from '../../utils/trendSearchConditions'; + +interface Props { + filters: TrendSearchRequest; + onChange: (next: TrendSearchRequest) => void; + onSearch: () => void; + searching?: boolean; +} + +function tfLabel(tf: string): string { + if (tf === '1M') return '1M'; + if (tf.endsWith('m')) return tf.replace('m', '분'); + if (tf.endsWith('h')) return tf.replace('h', '시간'); + if (tf.endsWith('d')) return tf.replace('d', '일'); + if (tf.endsWith('w')) return tf.replace('w', '주'); + return tf; +} + +function modeBadge(mode: TrendConditionMode): React.ReactNode { + if (mode === 'filter') return 필터; + if (mode === 'unsupported') return 미지원; + return 정렬; +} + +const TrendSearchFilterPanel: React.FC = ({ filters, onChange, onSearch, searching }) => { + const patch = (p: Partial) => onChange({ ...filters, ...p }); + + const sortOptions = useMemo(() => { + return TREND_CONDITION_DEFS.filter(c => { + if (c.mode !== 'sort') return false; + if (!filters[c.categoryKey]) return false; + return Boolean(filters[c.requestKey]); + }); + }, [filters]); + + const handleToggleCondition = (key: keyof TrendSearchRequest, checked: boolean, id: string, mode: TrendConditionMode) => { + const next = { ...filters, [key]: checked }; + if (checked && mode === 'sort' && !TREND_SORT_CONDITION_IDS.includes(filters.sortBy)) { + next.sortBy = id; + } + if (!checked && filters.sortBy === id) { + const fallback = TREND_CONDITION_DEFS.find( + c => c.mode === 'sort' && c.id !== id && Boolean(next[c.requestKey]), + ); + if (fallback) next.sortBy = fallback.id; + } + onChange(next); + }; + + return ( +
+
+ +
+

추세검색 조건

+

20개 조건 · 필터 + 정렬

+
+
+ + {TREND_CATEGORY_ORDER.map(cat => { + const items = TREND_CONDITION_DEFS.filter(c => c.category === cat.cat); + const catOn = filters[cat.key]; + return ( +
+
+ {cat.label} + +
+ {catOn && ( +
+ {items.map(item => { + const checked = Boolean(filters[item.requestKey]); + const disabled = item.mode === 'unsupported'; + return ( +
+
+ + {!disabled && ( + + {item.desc} + + )} +
+ {item.id === 'newHighCount' && checked && ( +
+ N= + patch({ newHighDays: Number(e.target.value) })} + /> + +
+ )} +
+ ); + })} +
+ )} +
+ ); + })} + +
+

주 정렬 기준

+ +
+ +
+

캔들 주기

+
+ {TREND_TIMEFRAMES.map(tf => ( + + ))} +
+
+ +
+

결과 개수

+
+ patch({ limit: Number(e.target.value) })} + /> + {filters.limit} +
+
+ + +
+ ); +}; + +export default TrendSearchFilterPanel; diff --git a/frontend/src/components/trendSearch/TrendSearchResultCard.tsx b/frontend/src/components/trendSearch/TrendSearchResultCard.tsx new file mode 100644 index 0000000..dc44e37 --- /dev/null +++ b/frontend/src/components/trendSearch/TrendSearchResultCard.tsx @@ -0,0 +1,132 @@ +import React, { useMemo } from 'react'; +import { getKoreanName } from '../../utils/marketNameCache'; +import type { TrendSearchResultDto } from '../../utils/trendSearchApi'; +import { tfLabelShort } from '../../utils/trendSearchMetrics'; +import type { Theme } from '../../types'; +import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData'; +import type { TickerData } from '../../hooks/useMarketTicker'; +import VirtualTargetQuote from '../virtual/VirtualTargetQuote'; +import VirtualLiveBadge from '../virtual/VirtualLiveBadge'; +import TrendSearchCardSignalPanel from './TrendSearchCardSignalPanel'; +import TrendSearchCardChart from './TrendSearchCardChart'; + +export type TrendSearchDisplayMode = 'summary' | 'chart'; + +interface Props { + result: TrendSearchResultDto; + timeframe: string; + displayMode: TrendSearchDisplayMode; + selected?: boolean; + onSelect?: () => void; + theme?: Theme; + chartRealtimeSource?: ChartRealtimeSource; + chartSeriesPriceLabels?: boolean; + ticker?: TickerData; + updatedAt?: number; + flash?: boolean; +} + +function resultToTicker(result: TrendSearchResultDto): TickerData { + const rate = result.changeRate / 100; + return { + market: result.market, + koreanName: result.koreanName, + tradePrice: result.currentPrice, + changeRate: rate, + changePrice: null, + accTradePrice24: 0, + accTradeVolume24: null, + openingPrice: null, + highPrice: null, + lowPrice: null, + change: result.changeRate > 0 ? 'RISE' : result.changeRate < 0 ? 'FALL' : 'EVEN', + }; +} + +const TrendSearchResultCard: React.FC = ({ + result, + timeframe, + displayMode, + selected = false, + onSelect, + theme = 'dark', + chartRealtimeSource = 'BACKEND_STOMP', + chartSeriesPriceLabels = true, + ticker, + updatedAt, + flash = false, +}) => { + const ko = result.koreanName || getKoreanName(result.market); + const sym = result.market.replace(/^KRW-/, ''); + const isChart = displayMode === 'chart'; + const quoteTicker = ticker ?? resultToTicker(result); + + const flashCls = useMemo(() => { + if (!flash) return ''; + return result.changeRate >= 0 ? 'tsd-card--flash-up' : 'tsd-card--flash-down'; + }, [flash, result.changeRate]); + + return ( +
{ + if (onSelect && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + onSelect(); + } + }} + role={onSelect ? 'button' : undefined} + tabIndex={onSelect ? 0 : undefined} + aria-pressed={onSelect ? selected : undefined} + > +
+
+
+ {ko} + {sym} +
+ 시간봉 {tfLabelShort(timeframe)} + {result.highMatch && HIGH MATCH} +
+
+ +
+ + {!isChart && ( + + {result.matchedCount}/{result.totalConditions} 조건 충족 + + )} + +
+ + {isChart ? ( + + ) : ( + + )} +
+ ); +}; + +export default TrendSearchResultCard; diff --git a/frontend/src/components/trendSearch/TrendSearchResultsCardGrid.tsx b/frontend/src/components/trendSearch/TrendSearchResultsCardGrid.tsx new file mode 100644 index 0000000..804365a --- /dev/null +++ b/frontend/src/components/trendSearch/TrendSearchResultsCardGrid.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import type { TrendSearchResultDto } from '../../utils/trendSearchApi'; +import type { Theme } from '../../types'; +import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData'; +import type { TickerData } from '../../hooks/useMarketTicker'; +import TrendSearchResultCard, { type TrendSearchDisplayMode } from './TrendSearchResultCard'; + +interface Props { + results: TrendSearchResultDto[]; + timeframe: string; + displayMode: TrendSearchDisplayMode; + loading?: boolean; + selectedMarket?: string | null; + onSelect?: (row: TrendSearchResultDto) => void; + flashMarkets?: Set; + theme?: Theme; + chartRealtimeSource?: ChartRealtimeSource; + chartSeriesPriceLabels?: boolean; + tickers?: Map; + lastUpdatedAt?: number; +} + +const TrendSearchResultsCardGrid: React.FC = ({ + results, + timeframe, + displayMode, + loading, + selectedMarket, + onSelect, + flashMarkets, + theme = 'dark', + chartRealtimeSource = 'BACKEND_STOMP', + chartSeriesPriceLabels = true, + tickers, + lastUpdatedAt, +}) => { + if (loading && results.length === 0) { + return ( +
+
+

스캔 중…

+
+
+ ); + } + + if (!loading && results.length === 0) { + return ( +
+
+

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

+
+
+ ); + } + + return ( +
+
+ {results.map(row => ( + onSelect(row) : undefined} + theme={theme} + chartRealtimeSource={chartRealtimeSource} + chartSeriesPriceLabels={chartSeriesPriceLabels} + ticker={tickers?.get(row.market)} + updatedAt={lastUpdatedAt} + flash={flashMarkets?.has(row.market)} + /> + ))} +
+
+ ); +}; + +export default TrendSearchResultsCardGrid; diff --git a/frontend/src/components/trendSearch/TrendSearchResultsGrid.tsx b/frontend/src/components/trendSearch/TrendSearchResultsGrid.tsx new file mode 100644 index 0000000..b5c36f9 --- /dev/null +++ b/frontend/src/components/trendSearch/TrendSearchResultsGrid.tsx @@ -0,0 +1,111 @@ +import React, { useMemo } from 'react'; +import type { TrendSearchResultDto } from '../../utils/trendSearchApi'; + +interface Props { + results: TrendSearchResultDto[]; + selectedMarket: string | null; + onSelect: (row: TrendSearchResultDto) => void; + loading?: boolean; + flashMarkets?: Set; +} + +function fmtPrice(n: number): string { + if (n >= 1_000_000) return n.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); + if (n >= 100) return n.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); + return n.toLocaleString('ko-KR', { maximumFractionDigits: 4 }); +} + +function fmtPct(n: number): string { + const sign = n >= 0 ? '+' : ''; + return `${sign}${n.toFixed(1)}%`; +} + +function coinLabel(market: string, name?: string): string { + const code = market.replace(/^KRW-/, ''); + return name ? `${code}/KRW` : `${code}/KRW`; +} + +const TrendSearchResultsGrid: React.FC = ({ + results, + selectedMarket, + onSelect, + loading, + flashMarkets, +}) => { + const avgMatch = useMemo(() => { + if (!results.length) return 0; + return Math.round(results.reduce((s, r) => s + r.matchRate, 0) / results.length); + }, [results]); + + return ( +
+
+
+

실시간 종목 일치율 그리드

+

Search Condition Match Rate % Sorting · 100ms 실시간 처리

+
+
+ 평균 {avgMatch}% + ● LIVE +
+
+ +
+ + + + + + + + + + + + {loading && results.length === 0 && ( + + )} + {!loading && results.length === 0 && ( + + )} + {results.map(row => { + const up = row.changeRate >= 0; + const active = selectedMarket === row.market; + const flash = flashMarkets?.has(row.market); + return ( + onSelect(row)} + > + + + + + + + ); + })} + +
종목명현재가전일대비일치율비고
스캔 중…
조건에 맞는 종목이 없습니다. 필터를 조정해 보세요.
+ {coinLabel(row.market, row.koreanName)} + {fmtPrice(row.currentPrice)}{fmtPct(row.changeRate)} +
+
+
+
+ {row.matchRate}% +
+
+ {row.highMatch && HIGH MATCH} + {!row.highMatch && row.matchRate >= 70 && MATCH} +
+
+
+ ); +}; + +export default TrendSearchResultsGrid; diff --git a/frontend/src/components/trendSearch/TrendSearchSignalPanel.tsx b/frontend/src/components/trendSearch/TrendSearchSignalPanel.tsx new file mode 100644 index 0000000..a4a9da2 --- /dev/null +++ b/frontend/src/components/trendSearch/TrendSearchSignalPanel.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import type { TrendSearchConditionDto } from '../../utils/trendSearchApi'; +import VirtualSignalTrafficLight from '../virtual/VirtualSignalTrafficLight'; +import type { TrafficLightState } from '../../utils/virtualSignalMetrics'; + +interface Props { + conditions: TrendSearchConditionDto[]; + matchRate: number; + matchedCount: number; + totalConditions: number; +} + +function resolveTraffic(matchRate: number, conditions: TrendSearchConditionDto[]): TrafficLightState { + if (matchRate >= 100) return 'blue'; + const hasPending = conditions.some(c => c.status === 'pending'); + const hasPartial = conditions.some(c => c.status === 'match'); + if (hasPending || hasPartial || matchRate >= 40) return 'yellow'; + return 'red'; +} + +const TrendSearchSignalPanel: React.FC = ({ + conditions, + matchRate, + matchedCount, + totalConditions, +}) => ( +
+
+
+ Signal Intelligence + {matchedCount}/{totalConditions} 조건 충족 +
+ +
+ +
+

REAL-TIME INDICATOR COMPARISON

+ + + + + + + + + + + + {conditions.length === 0 ? ( + + ) : conditions.map(c => ( + + + + + + + + ))} + +
TECHNICAL INDICATORCURRENTTHRESHOLDPROGRESSSTATUS
종목을 선택하세요
{c.label}{c.currentValue != null ? c.currentValue.toLocaleString() : '—'}{c.thresholdLabel} +
+
+
+
+ {c.progress}% +
+
+ + {c.status === 'match' ? 'Match' : c.status === 'pending' ? 'Pending' : 'Mismatch'} + +
+
+
+); + +export default TrendSearchSignalPanel; diff --git a/frontend/src/components/trendSearch/TrendSearchViewHeaderControls.tsx b/frontend/src/components/trendSearch/TrendSearchViewHeaderControls.tsx new file mode 100644 index 0000000..650f3ed --- /dev/null +++ b/frontend/src/components/trendSearch/TrendSearchViewHeaderControls.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import type { TrendSearchDisplayMode } from './TrendSearchResultCard'; + +interface Props { + displayMode: TrendSearchDisplayMode; + onDisplayModeChange: (mode: TrendSearchDisplayMode) => void; + autoRefresh: boolean; + onAutoRefreshChange: (v: boolean) => void; + searching?: boolean; + onRefresh: () => void; + resultCount?: number; +} + +const TrendSearchViewHeaderControls: React.FC = ({ + displayMode, + onDisplayModeChange, + autoRefresh, + onAutoRefreshChange, + searching, + onRefresh, + resultCount = 0, +}) => ( +
+
+
+ + +
+
+
+); + +export default TrendSearchViewHeaderControls; diff --git a/frontend/src/styles/trendSearchDashboard.css b/frontend/src/styles/trendSearchDashboard.css new file mode 100644 index 0000000..c838256 --- /dev/null +++ b/frontend/src/styles/trendSearchDashboard.css @@ -0,0 +1,805 @@ +/* GoldenChart 추세검색 — #131722 / #FFD700 테마 */ + +.bps-page--tsd { + --tsd-bg: #131722; + --tsd-gold: #ffd700; + --tsd-gold-dim: color-mix(in srgb, #ffd700 65%, #131722); + --tsd-match: #26a69a; + --tsd-mismatch: #ef5350; + --tsd-pending: #ffd700; + --tsd-card: color-mix(in srgb, #1e222d 92%, #131722); + --tsd-border: color-mix(in srgb, #ffd700 12%, #2a2e39); + --tsd-text: #d1d4dc; + --tsd-muted: #787b86; + background: var(--tsd-bg); +} + +.bps-page--tsd .bps-header-title { + color: var(--tsd-gold); + letter-spacing: 0.02em; +} + +.bps-page--tsd .bps-header-sub { + color: var(--tsd-muted); + font-size: 11px; +} + +.bps-page--tsd .bps-left, +.bps-page--tsd .bps-center, +.bps-page--tsd .bps-right { + background: var(--tsd-card); + border-color: var(--tsd-border); +} + +.bps-page--tsd .bps-right { + min-width: 380px; +} + +/* Header actions */ +.tsd-header-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.tsd-auto-refresh { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--tsd-muted); + cursor: pointer; +} + +.tsd-auto-refresh input { + accent-color: var(--tsd-gold); +} + +.tsd-refresh-btn { + padding: 6px 14px; + border-radius: 8px; + border: 1px solid color-mix(in srgb, var(--tsd-gold) 35%, transparent); + background: color-mix(in srgb, var(--tsd-gold) 8%, transparent); + color: var(--tsd-gold); + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +.tsd-refresh-btn:hover:not(:disabled) { + background: color-mix(in srgb, var(--tsd-gold) 18%, transparent); +} + +.tsd-refresh-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Left filter panel */ +.tsd-filter-panel { + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 12px; + height: 100%; + overflow-y: auto; + box-sizing: border-box; +} + +.tsd-panel-head { + display: flex; + gap: 10px; + align-items: flex-start; + padding-bottom: 10px; + border-bottom: 1px solid var(--tsd-border); +} + +.tsd-panel-icon { + font-size: 18px; + color: var(--tsd-gold); +} + +.tsd-panel-title { + margin: 0; + font-size: 13px; + font-weight: 700; + color: var(--tsd-gold); +} + +.tsd-panel-sub { + margin: 4px 0 0; + font-size: 10px; + color: var(--tsd-muted); +} + +.tsd-filter-cat { + border: 1px solid var(--tsd-border); + border-radius: 10px; + background: color-mix(in srgb, var(--tsd-bg) 40%, transparent); + overflow: hidden; +} + +.tsd-filter-cat--on { + border-color: color-mix(in srgb, var(--tsd-gold) 35%, var(--tsd-border)); +} + +.tsd-filter-cat-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + background: color-mix(in srgb, var(--tsd-gold) 4%, transparent); +} + +.tsd-filter-cat-en { + display: block; + font-size: 11px; + font-weight: 700; + color: var(--tsd-gold); +} + +.tsd-filter-cat-ko { + display: block; + font-size: 10px; + color: var(--tsd-muted); +} + +.tsd-toggle { + position: relative; + width: 36px; + height: 20px; + cursor: pointer; +} + +.tsd-toggle input { + opacity: 0; + width: 0; + height: 0; + position: absolute; +} + +.tsd-toggle-track { + position: absolute; + inset: 0; + border-radius: 10px; + background: #3a3f4b; + transition: background 0.2s; +} + +.tsd-toggle-track::before { + content: ''; + position: absolute; + width: 14px; + height: 14px; + left: 3px; + top: 3px; + border-radius: 50%; + background: #fff; + transition: transform 0.2s; +} + +.tsd-toggle input:checked + .tsd-toggle-track { + background: color-mix(in srgb, var(--tsd-gold) 75%, #3a3f4b); +} + +.tsd-toggle input:checked + .tsd-toggle-track::before { + transform: translateX(16px); +} + +.tsd-filter-items { + padding: 8px 12px 10px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.tsd-filter-item { + display: flex; + flex-direction: column; + gap: 6px; +} + +.tsd-filter-item-check { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + max-width: 48%; + font-size: 11px; + color: var(--tsd-text); + cursor: pointer; +} + +.tsd-filter-item-check input { + accent-color: var(--tsd-gold); + flex-shrink: 0; +} + +.tsd-filter-item-row { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.tsd-filter-item-desc-inline { + flex: 1; + min-width: 0; + font-size: 9px; + color: var(--tsd-muted); + line-height: 1.35; + text-align: right; +} + +.tsd-filter-item-text { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.tsd-filter-item-label { + line-height: 1.3; +} + +.tsd-filter-item-desc { + margin: 0; + padding-left: 22px; + font-size: 9px; + color: var(--tsd-muted); + line-height: 1.35; +} + +.tsd-filter-item--disabled { + opacity: 0.45; +} + +.tsd-cond-badge { + font-size: 8px; + font-weight: 700; + padding: 1px 5px; + border-radius: 4px; + letter-spacing: 0.02em; + flex-shrink: 0; +} + +.tsd-cond-badge--sort { + color: var(--tsd-gold); + border: 1px solid color-mix(in srgb, var(--tsd-gold) 40%, transparent); +} + +.tsd-cond-badge--filter { + color: #6eb5ff; + border: 1px solid color-mix(in srgb, #6eb5ff 40%, transparent); +} + +.tsd-cond-badge--na { + color: var(--tsd-muted); + border: 1px solid var(--tsd-border); +} + +.tsd-sort-select { + width: 100%; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--tsd-border); + background: var(--tsd-bg); + color: var(--tsd-text); + font-size: 11px; +} + +.tsd-filter-params { + display: flex; + gap: 6px; + flex-wrap: wrap; + padding-left: 22px; +} + +.tsd-num { + width: 42px; + padding: 4px 6px; + border-radius: 6px; + border: 1px solid var(--tsd-border); + background: var(--tsd-bg); + color: var(--tsd-text); + font-size: 10px; + text-align: center; +} + +.tsd-param-sep { + color: var(--tsd-muted); + align-self: center; +} + +.tsd-filter-section-title { + margin: 0 0 8px; + font-size: 10px; + font-weight: 600; + color: var(--tsd-gold-dim); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.tsd-tf-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 4px; +} + +.tsd-tf-btn { + padding: 5px 4px; + border-radius: 6px; + border: 1px solid var(--tsd-border); + background: transparent; + color: var(--tsd-muted); + font-size: 9px; + cursor: pointer; +} + +.tsd-tf-btn--on { + border-color: var(--tsd-gold); + background: color-mix(in srgb, var(--tsd-gold) 15%, transparent); + color: var(--tsd-gold); + font-weight: 700; +} + +.tsd-limit-row { + display: flex; + align-items: center; + gap: 10px; +} + +.tsd-slider { + flex: 1; + accent-color: var(--tsd-gold); +} + +.tsd-limit-val { + min-width: 24px; + text-align: center; + font-size: 12px; + font-weight: 700; + color: var(--tsd-gold); +} + +.tsd-search-btn { + margin-top: auto; + padding: 10px; + border: none; + border-radius: 10px; + background: linear-gradient(135deg, color-mix(in srgb, var(--tsd-gold) 90%, #fff), var(--tsd-gold-dim)); + color: #131722; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.tsd-search-btn:disabled { + opacity: 0.6; + cursor: wait; +} + +/* Center stack */ +.tsd-center-stack { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + gap: 0; +} + +.tsd-results { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: 10px 12px; +} + +.tsd-results-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 10px; + margin-bottom: 10px; + flex-shrink: 0; +} + +.tsd-results-meta { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.tsd-meta-chip { + padding: 3px 8px; + border-radius: 6px; + font-size: 9px; + font-weight: 600; + background: color-mix(in srgb, var(--tsd-gold) 10%, transparent); + color: var(--tsd-gold-dim); + border: 1px solid var(--tsd-border); +} + +.tsd-meta-chip--live { + color: var(--tsd-match); + border-color: color-mix(in srgb, var(--tsd-match) 30%, transparent); +} + +.tsd-results-table-wrap { + flex: 1; + min-height: 0; + overflow: auto; + border: 1px solid var(--tsd-border); + border-radius: 10px; +} + +.tsd-results-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; +} + +.tsd-results-table thead { + position: sticky; + top: 0; + z-index: 1; + background: color-mix(in srgb, var(--tsd-bg) 95%, var(--tsd-gold)); +} + +.tsd-results-table th { + padding: 8px 10px; + text-align: left; + font-size: 9px; + font-weight: 600; + color: var(--tsd-muted); + text-transform: uppercase; + letter-spacing: 0.03em; + border-bottom: 1px solid var(--tsd-border); +} + +.tsd-results-table td { + padding: 9px 10px; + border-bottom: 1px solid color-mix(in srgb, var(--tsd-border) 60%, transparent); + color: var(--tsd-text); +} + +.tsd-results-table tbody tr { + cursor: pointer; + transition: background 0.15s; +} + +.tsd-results-table tbody tr:hover { + background: color-mix(in srgb, var(--tsd-gold) 6%, transparent); +} + +.tsd-row--sel td { + background: color-mix(in srgb, var(--tsd-gold) 12%, transparent) !important; + box-shadow: inset 3px 0 0 var(--tsd-gold); +} + +.tsd-row--flash-up td { + animation: tsd-flash-up 0.55s ease; +} + +.tsd-row--flash-down td { + animation: tsd-flash-down 0.55s ease; +} + +@keyframes tsd-flash-up { + 0%, 100% { background: transparent; } + 40% { background: color-mix(in srgb, var(--tsd-match) 25%, transparent); } +} + +@keyframes tsd-flash-down { + 0%, 100% { background: transparent; } + 40% { background: color-mix(in srgb, var(--tsd-mismatch) 20%, transparent); } +} + +.tsd-col-symbol .tsd-symbol { + font-weight: 700; + color: var(--tsd-text); +} + +.tsd-col-price { + font-weight: 700; + color: var(--tsd-gold); + font-variant-numeric: tabular-nums; +} + +.tsd-col-change.up { color: var(--tsd-match); font-weight: 600; } +.tsd-col-change.down { color: var(--tsd-mismatch); font-weight: 600; } + +.tsd-match-bar-wrap { + display: flex; + align-items: center; + gap: 8px; +} + +.tsd-match-bar { + flex: 1; + height: 6px; + border-radius: 3px; + background: color-mix(in srgb, var(--tsd-mismatch) 15%, #2a2e39); + overflow: hidden; + min-width: 60px; +} + +.tsd-match-bar-fill { + height: 100%; + border-radius: 3px; + background: linear-gradient(90deg, var(--tsd-match), color-mix(in srgb, var(--tsd-gold) 40%, var(--tsd-match))); +} + +.tsd-match-pct { + font-size: 10px; + font-weight: 700; + color: var(--tsd-match); + min-width: 32px; +} + +.tsd-badge-high { + padding: 2px 8px; + border-radius: 4px; + font-size: 8px; + font-weight: 800; + letter-spacing: 0.06em; + background: var(--tsd-gold); + color: #131722; +} + +.tsd-badge-mid { + padding: 2px 6px; + border-radius: 4px; + font-size: 8px; + font-weight: 700; + color: var(--tsd-match); + border: 1px solid color-mix(in srgb, var(--tsd-match) 40%, transparent); +} + +.tsd-empty { + text-align: center; + padding: 24px !important; + color: var(--tsd-muted); + font-size: 11px; +} + +/* Signal panel */ +.tsd-signal-panel { + flex-shrink: 0; + border-top: 1px solid var(--tsd-border); + padding: 10px 12px 12px; + max-height: 42%; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.tsd-signal-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.tsd-signal-label { + display: block; + font-size: 10px; + font-weight: 700; + color: var(--tsd-gold); + letter-spacing: 0.05em; +} + +.tsd-signal-count { + font-size: 10px; + color: var(--tsd-muted); +} + +.tsd-compare-wrap { + flex: 1; + min-height: 0; + overflow: auto; +} + +.tsd-compare-title { + margin: 0 0 6px; + font-size: 9px; + font-weight: 600; + color: var(--tsd-muted); + letter-spacing: 0.06em; +} + +.tsd-compare-table { + width: 100%; + border-collapse: collapse; + font-size: 10px; +} + +.tsd-compare-table th { + padding: 5px 6px; + text-align: left; + font-size: 8px; + color: var(--tsd-muted); + border-bottom: 1px solid var(--tsd-border); +} + +.tsd-compare-table td { + padding: 5px 6px; + border-bottom: 1px solid color-mix(in srgb, var(--tsd-border) 50%, transparent); + color: var(--tsd-text); +} + +.tsd-progress-cell { + display: flex; + align-items: center; + gap: 6px; +} + +.tsd-progress-bar { + flex: 1; + height: 4px; + border-radius: 2px; + background: #2a2e39; + min-width: 40px; +} + +.tsd-progress-fill { + height: 100%; + border-radius: 2px; +} + +.tsd-progress-fill--match { background: var(--tsd-match); } +.tsd-progress-fill--pending { background: var(--tsd-pending); } +.tsd-progress-fill--mismatch { background: var(--tsd-mismatch); } + +.tsd-status { + font-size: 9px; + font-weight: 700; + text-transform: uppercase; +} + +.tsd-status--match { color: var(--tsd-match); } +.tsd-status--pending { color: var(--tsd-pending); } +.tsd-status--mismatch { color: var(--tsd-mismatch); } + +/* Chart panel */ +.tsd-chart-panel { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.tsd-chart-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid var(--tsd-border); + flex-shrink: 0; +} + +.tsd-chart-toolbar-title { + font-size: 11px; + font-weight: 700; + color: var(--tsd-gold); +} + +.tsd-chart-tools { + display: flex; + gap: 4px; +} + +.tsd-chart-tool-btn { + width: 28px; + height: 26px; + border-radius: 6px; + border: 1px solid var(--tsd-border); + background: color-mix(in srgb, var(--tsd-bg) 60%, transparent); + color: var(--tsd-muted); + font-size: 12px; + cursor: pointer; +} + +.tsd-chart-tool-btn:hover { + border-color: var(--tsd-gold); + color: var(--tsd-gold); +} + +.tsd-chart-body { + flex: 1; + min-height: 0; + position: relative; +} + +.tsd-chart-loading { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--tsd-muted); + font-size: 12px; + z-index: 2; + pointer-events: none; +} + +.tsd-chart-placeholder { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--tsd-muted); + font-size: 12px; + text-align: center; +} + +.tsd-chart-placeholder span { + font-size: 36px; + opacity: 0.5; +} + +/* Card grid (가상투자 vtd 스타일 재사용) */ +.bps-page--tsd.bps-page--vtd .bps-center { + padding: 0; + background: var(--tsd-bg); +} + +.tsd-header-view { + flex-wrap: wrap; + gap: 10px; +} + +.tsd-result-count { + font-size: 11px; + color: var(--tsd-muted); + font-weight: 600; +} + +.bps-page--tsd .vtd-grid-wrap { + container-type: inline-size; + container-name: vtd-wrap; + height: 100%; +} + +.bps-page--tsd .vtd-grid--chart-mode { + grid-auto-rows: minmax(560px, auto); +} + +.tsd-card-high-badge { + font-size: 8px; + font-weight: 700; + color: var(--tsd-gold); + border: 1px solid color-mix(in srgb, var(--tsd-gold) 45%, transparent); + border-radius: 4px; + padding: 1px 5px; + margin-left: 6px; +} + +.tsd-card--flash-up { + animation: tsd-flash-up 0.55s ease-out; +} + +.tsd-card--flash-down { + animation: tsd-flash-down 0.55s ease-out; +} + +@keyframes tsd-flash-up { + from { box-shadow: inset 0 0 0 1px color-mix(in srgb, #26a69a 70%, transparent); } + to { box-shadow: none; } +} + +@keyframes tsd-flash-down { + from { box-shadow: inset 0 0 0 1px color-mix(in srgb, #ef5350 70%, transparent); } + to { box-shadow: none; } +} + +@media (max-width: 1280px) { + .bps-page--tsd .bps-right { + min-width: 300px; + } + .tsd-tf-grid { + grid-template-columns: repeat(3, 1fr); + } +} diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index 139e3f9..0fa1de0 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -24,10 +24,17 @@ import { IndicatorFillPrimitive } from './IndicatorFillPrimitive'; import { BollingerBandFillPrimitive } from './BollingerBandFillPrimitive'; import { resolveBbBandBackground } from './bollingerConfig'; import type { OHLCVBar, ChartType, Theme, IndicatorConfig, Timeframe } from '../types'; +import { formatChartAxisPrice } from './dataGenerator'; import { formatLwcTime, formatLwcTickMark, DEFAULT_DISPLAY_TIMEZONE } from './timezone'; import { sortIndicatorsForPaneLoad } from './indicatorPaneMerge'; import { calculateIndicator, enrichIndicatorConfig, getIndicatorDef, getHLineLabel, type PlotData, type MarkerData } from './indicatorRegistry'; import { IchimokuCloudPlugin, type IchimokuCloudPoint } from './IchimokuCloudPlugin'; + +const MAIN_PRICE_FORMAT = { + type: 'custom' as const, + minMove: 0.001, + formatter: formatChartAxisPrice, +}; import { getIchimokuPlotTitle, isIchimokuCloudVisible, @@ -378,26 +385,30 @@ export class ChartManager { upColor: t.upColor, downColor: t.downColor, borderUpColor: t.upColor, borderDownColor: t.downColor, wickUpColor: t.upColor, wickDownColor: t.downColor, + priceFormat: MAIN_PRICE_FORMAT, }); case 'bar': - return this.chart.addSeries(BarSeries, { upColor: t.upColor, downColor: t.downColor }); + return this.chart.addSeries(BarSeries, { upColor: t.upColor, downColor: t.downColor, priceFormat: MAIN_PRICE_FORMAT }); case 'line': - return this.chart.addSeries(LineSeries, { color: '#2962FF', lineWidth: 2, crosshairMarkerVisible: true }); + return this.chart.addSeries(LineSeries, { color: '#2962FF', lineWidth: 2, crosshairMarkerVisible: true, priceFormat: MAIN_PRICE_FORMAT }); case 'area': return this.chart.addSeries(AreaSeries, { lineColor: '#2962FF', topColor: '#2962FF44', bottomColor: '#2962FF00', lineWidth: 2, + priceFormat: MAIN_PRICE_FORMAT, }); case 'baseline': return this.chart.addSeries(BaselineSeries, { baseValue: { type: 'price', price: 0 }, topLineColor: '#26a69a', topFillColor1: '#26a69a44', topFillColor2: '#26a69a00', bottomLineColor: '#ef5350', bottomFillColor1: '#ef535000', bottomFillColor2: '#ef535044', + priceFormat: MAIN_PRICE_FORMAT, }); default: return this.chart.addSeries(CandlestickSeries, { upColor: t.upColor, downColor: t.downColor, borderUpColor: t.upColor, borderDownColor: t.downColor, wickUpColor: t.upColor, wickDownColor: t.downColor, + priceFormat: MAIN_PRICE_FORMAT, }); } } @@ -419,7 +430,7 @@ export class ChartManager { const timeFormatter = (time: Time) => formatLwcTime(time as number | { year: number; month: number; day: number }, tf, tz); this.chart.applyOptions({ - localization: { timeFormatter }, + localization: { timeFormatter, priceFormatter: formatChartAxisPrice }, timeScale: { tickMarkFormatter: this._tickMarkFormatter() }, }); } diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 650ba3f..6e4cb5f 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1124,3 +1124,112 @@ export async function pinCandleWatch(market: string, candleType: string): Promis /* pin 실패해도 평가 API는 시도 */ } } + +// ── 추세검색 ───────────────────────────────────────────────────────────────── + +export interface TrendSearchConditionDto { + id: string; + category: string; + label: string; + currentValue: number | null; + thresholdLabel: string; + progress: number; + status: 'match' | 'pending' | 'mismatch'; +} + +export interface TrendSearchResultDto { + market: string; + koreanName: string; + currentPrice: number; + changeRate: number; + matchRate: number; + matchedCount: number; + totalConditions: number; + highMatch: boolean; + conditions: TrendSearchConditionDto[]; +} + +export interface TrendSearchRequest { + timeframe: string; + limit: number; + scanLimit: number; + sortBy: string; + newHighDays: number; + priceEnabled: boolean; + volumeEnabled: boolean; + flowEnabled: boolean; + fundamentalEnabled: boolean; + near52wHigh: boolean; + ret3m: boolean; + ret6m: boolean; + maDeviation: boolean; + maFullAlign: boolean; + stage2: boolean; + relativeStrength: boolean; + newHighCount: boolean; + tradeAmount: boolean; + volVs5dAvg: boolean; + turnover: boolean; + bidAskRatio: boolean; + smartMoney: boolean; + instConsecutive: boolean; + rsiHigh: boolean; + macdPeak: boolean; + lightCredit: boolean; + earningsGrowth: boolean; + roe: boolean; + opMargin: boolean; + pegBelow1: boolean; +} + +export const DEFAULT_TREND_SEARCH_REQUEST: TrendSearchRequest = { + timeframe: '1d', + limit: 20, + scanLimit: 80, + sortBy: 'near52wHigh', + newHighDays: 20, + priceEnabled: true, + volumeEnabled: true, + flowEnabled: true, + fundamentalEnabled: false, + near52wHigh: true, + ret3m: false, + ret6m: false, + maDeviation: true, + maFullAlign: true, + stage2: false, + relativeStrength: true, + newHighCount: false, + tradeAmount: false, + volVs5dAvg: true, + turnover: false, + bidAskRatio: false, + smartMoney: false, + instConsecutive: false, + rsiHigh: true, + macdPeak: false, + lightCredit: false, + earningsGrowth: false, + roe: false, + opMargin: false, + pegBelow1: false, +}; + +export const TREND_TIMEFRAMES = ['1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1d', '1w', '1M'] as const; + +export async function scanTrendSearch(body: TrendSearchRequest): Promise { + return (await request('/trend-search/scan', { + method: 'POST', + body: JSON.stringify(body), + })) ?? []; +} + +export async function fetchTrendSearchDetail( + market: string, + timeframe?: string, +): Promise { + const params = new URLSearchParams({ market }); + if (timeframe) params.set('timeframe', timeframe); + return request(`/trend-search/detail?${params}`); +} + diff --git a/frontend/src/utils/dataGenerator.ts b/frontend/src/utils/dataGenerator.ts index 98d2c1b..ab1dc87 100644 --- a/frontend/src/utils/dataGenerator.ts +++ b/frontend/src/utils/dataGenerator.ts @@ -88,6 +88,15 @@ export function formatPrice(price: number): string { return price.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 6 }); } +/** 실시간 차트 우측 가격축·크로스헤어 라벨 (10만원 미만: 소수 3자리, 이상: 정수) */ +export function formatChartAxisPrice(price: number): string { + if (!Number.isFinite(price)) return String(price); + if (Math.abs(price) >= 100_000) { + return price.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); + } + return price.toLocaleString('en-US', { minimumFractionDigits: 3, maximumFractionDigits: 3 }); +} + export function formatTime(ts: number, timeframe: Timeframe): string { return formatUnixForChart(ts, timeframe); } diff --git a/frontend/src/utils/permissions.ts b/frontend/src/utils/permissions.ts index 24ae3a6..f417cf8 100644 --- a/frontend/src/utils/permissions.ts +++ b/frontend/src/utils/permissions.ts @@ -12,7 +12,7 @@ export type SettingsCategoryId = | 'paper' | 'alert' | 'network' | 'admin'; export const TOP_MENU_IDS: TopMenuId[] = [ - 'dashboard', 'chart', 'paper', 'virtual', 'strategy-editor', 'backtest', 'settings', + 'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'settings', ]; export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [ @@ -20,7 +20,7 @@ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [ ]; export const ALL_MENU_IDS = [ - 'dashboard', 'chart', 'paper', 'virtual', 'strategy', 'strategy-editor', 'backtest', 'notifications', 'settings', + 'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy', 'strategy-editor', 'backtest', 'notifications', 'settings', ...SETTINGS_MENU_IDS.map(id => `settings_${id}` as const), ] as const; @@ -31,6 +31,7 @@ export const MENU_LABELS: Record = { chart: '실시간차트', paper: '모의투자', virtual: '가상투자', + 'trend-search': '추세검색', strategy: '투자전략', 'strategy-editor': '전략편집기', backtest: '백테스팅', diff --git a/frontend/src/utils/trendSearchApi.ts b/frontend/src/utils/trendSearchApi.ts new file mode 100644 index 0000000..f9b759d --- /dev/null +++ b/frontend/src/utils/trendSearchApi.ts @@ -0,0 +1,20 @@ +/** + * 추세검색 API + */ +import { scanTrendSearch as apiScan, fetchTrendSearchDetail as apiDetail } from './backendApi'; + +export type { + TrendSearchConditionDto, + TrendSearchResultDto, + TrendSearchRequest, +} from './backendApi'; + +export { DEFAULT_TREND_SEARCH_REQUEST, TREND_TIMEFRAMES } from './backendApi'; + +export async function scanTrendSearch(body: import('./backendApi').TrendSearchRequest) { + return apiScan(body); +} + +export async function fetchTrendSearchDetail(market: string, timeframe?: string) { + return apiDetail(market, timeframe); +} diff --git a/frontend/src/utils/trendSearchConditions.ts b/frontend/src/utils/trendSearchConditions.ts new file mode 100644 index 0000000..af71020 --- /dev/null +++ b/frontend/src/utils/trendSearchConditions.ts @@ -0,0 +1,240 @@ +/** 추세검색 20개 조건 정의 */ +export type TrendConditionMode = 'sort' | 'filter' | 'unsupported'; + +export interface TrendConditionDef { + id: string; + category: 'price' | 'volume' | 'flow' | 'fundamental'; + categoryLabel: string; + label: string; + desc: string; + mode: TrendConditionMode; + requestKey: keyof import('./backendApi').TrendSearchRequest; + categoryKey: keyof Pick< + import('./backendApi').TrendSearchRequest, + 'priceEnabled' | 'volumeEnabled' | 'flowEnabled' | 'fundamentalEnabled' + >; +} + +export const TREND_CONDITION_DEFS: TrendConditionDef[] = [ + { + id: 'near52wHigh', + category: 'price', + categoryLabel: 'Ⅰ. 가격·탄력성', + label: '52주 신고가 근접률', + desc: '1년 최고가 대비 현재가 근접도 (높을수록 상단)', + mode: 'sort', + requestKey: 'near52wHigh', + categoryKey: 'priceEnabled', + }, + { + id: 'ret3m', + category: 'price', + categoryLabel: 'Ⅰ. 가격·탄력성', + label: '3개월 수익률', + desc: '최근 12주 누적 상승률', + mode: 'sort', + requestKey: 'ret3m', + categoryKey: 'priceEnabled', + }, + { + id: 'ret6m', + category: 'price', + categoryLabel: 'Ⅰ. 가격·탄력성', + label: '6개월 수익률', + desc: '최근 24주 누적 상승률', + mode: 'sort', + requestKey: 'ret6m', + categoryKey: 'priceEnabled', + }, + { + id: 'maDeviation', + category: 'price', + categoryLabel: 'Ⅰ. 가격·탄력성', + label: '이동평균 이격도 (20·60일)', + desc: '20·60일선 대비 주가 이격률', + mode: 'sort', + requestKey: 'maDeviation', + categoryKey: 'priceEnabled', + }, + { + id: 'maFullAlign', + category: 'price', + categoryLabel: 'Ⅰ. 가격·탄력성', + label: '이동평균 완전 정배열', + desc: '주가 > 5 > 20 > 60 > 120 > 200일선', + mode: 'filter', + requestKey: 'maFullAlign', + categoryKey: 'priceEnabled', + }, + { + id: 'stage2', + category: 'price', + categoryLabel: 'Ⅰ. 가격·탄력성', + label: "마크 미너비니 Stage 2", + desc: '150·200일선 위 + 200일선 1개월↑', + mode: 'filter', + requestKey: 'stage2', + categoryKey: 'priceEnabled', + }, + { + id: 'relativeStrength', + category: 'price', + categoryLabel: 'Ⅰ. 가격·탄력성', + label: '상대강도 (RS vs BTC)', + desc: 'BTC 대비 초과 수익률', + mode: 'sort', + requestKey: 'relativeStrength', + categoryKey: 'priceEnabled', + }, + { + id: 'newHighCount', + category: 'price', + categoryLabel: 'Ⅰ. 가격·탄력성', + label: '최근 N일 신고가 달성 횟수', + desc: '최근 구간 신고가 경신 일수', + mode: 'sort', + requestKey: 'newHighCount', + categoryKey: 'priceEnabled', + }, + { + id: 'tradeAmount', + category: 'volume', + categoryLabel: 'Ⅱ. 거래량·대금', + label: '당일 거래대금', + desc: '24h 누적 거래대금', + mode: 'sort', + requestKey: 'tradeAmount', + categoryKey: 'volumeEnabled', + }, + { + id: 'volVs5dAvg', + category: 'volume', + categoryLabel: 'Ⅱ. 거래량·대금', + label: '5일 평균 대비 거래대금 증가율', + desc: '5일 평균 대비 당일 거래대금', + mode: 'sort', + requestKey: 'volVs5dAvg', + categoryKey: 'volumeEnabled', + }, + { + id: 'turnover', + category: 'volume', + categoryLabel: 'Ⅱ. 거래량·대금', + label: '거래량 회전율', + desc: '20일 평균 대비 당일 거래량 비율', + mode: 'sort', + requestKey: 'turnover', + categoryKey: 'volumeEnabled', + }, + { + id: 'bidAskRatio', + category: 'volume', + categoryLabel: 'Ⅱ. 거래량·대금', + label: '매수 대기 잔량 비율', + desc: '호가 매수잔량 / 매도잔량', + mode: 'sort', + requestKey: 'bidAskRatio', + categoryKey: 'volumeEnabled', + }, + { + id: 'smartMoney', + category: 'flow', + categoryLabel: 'Ⅲ. 수급·심리', + label: '스마트머니 누적 (OBV)', + desc: 'OBV 20일 누적 상승폭 (기관·외국인 대용)', + mode: 'sort', + requestKey: 'smartMoney', + categoryKey: 'flowEnabled', + }, + { + id: 'instConsecutive', + category: 'flow', + categoryLabel: 'Ⅲ. 수급·심리', + label: '연속 순매수 일수', + desc: '양봉+거래량 증가 연속 일수 (연기금·투신 대용)', + mode: 'sort', + requestKey: 'instConsecutive', + categoryKey: 'flowEnabled', + }, + { + id: 'rsiHigh', + category: 'flow', + categoryLabel: 'Ⅲ. 수급·심리', + label: 'RSI (상대강도지수)', + desc: 'RSI 14 — 높을수록 강한 모멘텀', + mode: 'sort', + requestKey: 'rsiHigh', + categoryKey: 'flowEnabled', + }, + { + id: 'macdPeak', + category: 'flow', + categoryLabel: 'Ⅲ. 수급·심리', + label: 'MACD 오실레이터 최고치', + desc: '최근 20일 MACD 히스토그램 신고가', + mode: 'sort', + requestKey: 'macdPeak', + categoryKey: 'flowEnabled', + }, + { + id: 'lightCredit', + category: 'flow', + categoryLabel: 'Ⅲ. 수급·심리', + label: '거래량 감소 + 주가 상승', + desc: '매물 소화 후 가벼운 상승 (신용잔고↓ 대용)', + mode: 'filter', + requestKey: 'lightCredit', + categoryKey: 'flowEnabled', + }, + { + id: 'earningsGrowth', + category: 'fundamental', + categoryLabel: 'Ⅳ. 펀더멘털', + label: '분기 영업이익 성장률 (YoY)', + desc: '주식 전용 — 코인 미지원', + mode: 'unsupported', + requestKey: 'earningsGrowth', + categoryKey: 'fundamentalEnabled', + }, + { + id: 'roe', + category: 'fundamental', + categoryLabel: 'Ⅳ. 펀더멘털', + label: 'ROE (자기자본이익률)', + desc: '주식 전용 — 코인 미지원', + mode: 'unsupported', + requestKey: 'roe', + categoryKey: 'fundamentalEnabled', + }, + { + id: 'opMargin', + category: 'fundamental', + categoryLabel: 'Ⅳ. 펀더멘털', + label: '매출액 영업이익률', + desc: '주식 전용 — 코인 미지원', + mode: 'unsupported', + requestKey: 'opMargin', + categoryKey: 'fundamentalEnabled', + }, + { + id: 'pegBelow1', + category: 'fundamental', + categoryLabel: 'Ⅳ. 펀더멘털', + label: 'PEG 1.0 이하', + desc: '주식 전용 — 코인 미지원', + mode: 'unsupported', + requestKey: 'pegBelow1', + categoryKey: 'fundamentalEnabled', + }, +]; + +export const TREND_SORT_CONDITION_IDS = TREND_CONDITION_DEFS + .filter(c => c.mode === 'sort') + .map(c => c.id); + +export const TREND_CATEGORY_ORDER = [ + { key: 'priceEnabled' as const, label: 'Ⅰ. 가격·탄력성', cat: 'price' as const }, + { key: 'volumeEnabled' as const, label: 'Ⅱ. 거래량·대금', cat: 'volume' as const }, + { key: 'flowEnabled' as const, label: 'Ⅲ. 수급·심리', cat: 'flow' as const }, + { key: 'fundamentalEnabled' as const, label: 'Ⅳ. 펀더멘털', cat: 'fundamental' as const }, +]; diff --git a/frontend/src/utils/trendSearchMetrics.ts b/frontend/src/utils/trendSearchMetrics.ts new file mode 100644 index 0000000..1120b11 --- /dev/null +++ b/frontend/src/utils/trendSearchMetrics.ts @@ -0,0 +1,44 @@ +import type { TrendSearchConditionDto } from './trendSearchApi'; +import type { ConditionMetric, ConditionStatus } from './virtualSignalMetrics'; +import type { VirtualConditionRow } from './virtualStrategyConditions'; + +export function buildTrendSearchMetrics(conditions: TrendSearchConditionDto[]): ConditionMetric[] { + return conditions.map(c => { + const status: ConditionStatus = c.status === 'match' + ? 'match' + : c.status === 'pending' + ? 'pending' + : 'nomatch'; + + const row: VirtualConditionRow & { currentValue: number | null } = { + id: c.id, + indicatorType: c.category, + displayName: c.label, + conditionType: 'GTE', + conditionLabel: c.label, + targetValue: null, + timeframe: '1d', + side: 'buy', + plotKey: c.id, + satisfied: c.status === 'match' ? true : c.status === 'mismatch' ? false : null, + thresholdLabel: c.thresholdLabel, + currentValue: c.currentValue ?? null, + }; + + return { + row, + status, + matchRate: c.progress, + progress: c.progress, + }; + }); +} + +export function tfLabelShort(tf: string): string { + if (tf === '1M') return '1M'; + if (tf.endsWith('m')) return `${tf.replace('m', '')}분`; + if (tf.endsWith('h')) return `${tf.replace('h', '')}시간`; + if (tf.endsWith('d')) return `${tf.replace('d', '')}일`; + if (tf.endsWith('w')) return `${tf.replace('w', '')}주`; + return tf; +}