추세검색 화면 적용

This commit is contained in:
Macbook
2026-05-26 00:51:07 +09:00
parent 1e950c7db4
commit 1e11884fef
29 changed files with 3753 additions and 71 deletions
@@ -7,7 +7,7 @@ public final class MenuIds {
private MenuIds() {} private MenuIds() {}
public static final List<String> ALL = List.of( public static final List<String> 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_general", "settings_chart", "settings_indicators", "settings_backtest",
"settings_strategy", "settings_paper", "settings_alert", "settings_network", "settings_admin" "settings_strategy", "settings_paper", "settings_alert", "settings_network", "settings_admin"
); );
@@ -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<List<TrendSearchResultDto>> scan(@RequestBody TrendSearchRequest request) {
log.info("[TrendSearch] scan tf={} limit={}", request.getTimeframe(), request.getLimit());
return ResponseEntity.ok(trendSearchService.scan(request));
}
@GetMapping("/detail")
public ResponseEntity<TrendSearchResultDto> 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));
}
}
@@ -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;
}
@@ -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;
}
@@ -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<TrendSearchConditionDto> conditions;
}
@@ -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<TrendSearchResultDto> 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<String> markets = fetchKrwMarketsByVolume(scanLimit);
if (markets.isEmpty()) return List.of();
Map<String, TickerSnap> tickers = fetchTickers(markets);
Map<String, Double> bidAskRatios = r.isBidAskRatio() ? fetchBidAskRatios(markets) : Map.of();
List<CandleBarDto> 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<ScoredMarket> scored = new ArrayList<>();
for (String market : markets) {
try {
List<CandleBarDto> 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<String, Double> sortScores = buildSortScores(scored, r);
String sortKey = resolveSortKey(r);
Comparator<ScoredMarket> cmp = Comparator
.comparingDouble((ScoredMarket s) -> sortScores.getOrDefault(s.market, 0.0)).reversed()
.thenComparingDouble(s -> s.metrics.matchScore).reversed();
List<TrendSearchResultDto> 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<CandleBarDto> candles = historicalDataService.getHistory(market, tf, null, barCount);
if (candles.isEmpty()) {
return TrendSearchResultDto.builder()
.market(market).matchRate(0).conditions(List.of()).build();
}
Map<String, TickerSnap> tickers = fetchTickers(List.of(market));
TickerSnap tick = tickers.getOrDefault(market, TickerSnap.empty(market));
Map<String, Double> bidAsk = r.isBidAskRatio() ? fetchBidAskRatios(List.of(market)) : Map.of();
double btcRet3m = 0, btcRet6m = 0;
if (r.isRelativeStrength()) {
List<CandleBarDto> 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<String, Double> sortScores = Map.of(resolveSortKey(r), m.getSortValue(resolveSortKey(r)));
return toResult(sm, r, sortScores);
}
// ── Metrics ─────────────────────────────────────────────────────────────
private MetricValues computeMetrics(BarSeries series, List<CandleBarDto> 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<String, Double> buildSortScores(List<ScoredMarket> scored, TrendSearchRequest r) {
Map<String, List<Double>> buckets = new HashMap<>();
for (ScoredMarket s : scored) {
for (String id : enabledSortIds(r)) {
buckets.computeIfAbsent(id, k -> new ArrayList<>()).add(s.metrics.getSortValue(id));
}
}
Map<String, Double> 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<String> enabledSortIds(TrendSearchRequest r) {
List<String> 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<String> enabled = enabledSortIds(r);
return enabled.isEmpty() ? "near52wHigh" : enabled.get(0);
}
private TrendSearchResultDto toResult(ScoredMarket s, TrendSearchRequest r, Map<String, Double> sortScores) {
List<TrendSearchConditionDto> 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<TrendSearchConditionDto> buildConditions(MetricValues m, TrendSearchRequest r,
Map<String, Double> sortScores, String market) {
List<TrendSearchConditionDto> 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<TrendSearchConditionDto> 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<TrendSearchConditionDto> 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<TrendSearchConditionDto> 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<String, Double> 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<CandleBarDto> 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<CandleBarDto> 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<CandleBarDto> 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<CandleBarDto> 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<CandleBarDto> 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<Double> 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<String> 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<Map<String, Object>> all = objectMapper.readValue(marketsJson, new TypeReference<>() {});
List<String> 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<String, TickerSnap> 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<String, TickerSnap> fetchTickers(List<String> markets) {
if (markets.isEmpty()) return Map.of();
try {
WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build();
Map<String, TickerSnap> map = new HashMap<>();
for (int i = 0; i < markets.size(); i += 100) {
List<String> 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<String, Double> fetchBidAskRatios(List<String> markets) {
Map<String, Double> out = new HashMap<>();
try {
WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build();
for (int i = 0; i < markets.size(); i += 10) {
List<String> 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<CandleBarDto> 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<CandleBarDto> 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;
};
}
}
}
@@ -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);
+228 -7
View File
@@ -4110,24 +4110,27 @@ html.theme-blue {
.multi-chart-grid { .multi-chart-grid {
display: grid; display: grid;
flex: 1; flex: 1;
/* 높이를 명시해야 fr 행이 계산된다 */
height: 100%; height: 100%;
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
gap: 2px; gap: 12px;
background: var(--border, #2a2e3a); padding: 8px 10px 12px;
background: var(--bg, #131722);
overflow: hidden; overflow: hidden;
align-content: stretch;
} }
.multi-slot-wrap { .multi-slot-wrap {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
background: var(--bg, #131722); background: var(--panel, #1e222d);
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
/* grid stretch로 높이가 정해지므로 height 상속 허용 */
display: flex; display: flex;
flex-direction: column; 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; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
/* 부모 multi-slot-wrap (flex col)이 flex:1을 주므로 이 쪽도 flex:1 */
flex: 1; flex: 1;
min-height: 0; min-height: 0;
border: 2px solid transparent; border: 2px solid transparent;
transition: border-color .15s; transition: border-color .15s, box-shadow .15s;
} }
.chart-slot.active { .chart-slot.active {
border-color: var(--accent, #2962ff); 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 { .slot-header {
display: flex; display: flex;
+13
View File
@@ -70,6 +70,7 @@ import { BacktestHistoryPage } from './components/BacktestHistoryPage';
import SettingsPage from './components/SettingsPage'; import SettingsPage from './components/SettingsPage';
import PaperTradingPage from './components/PaperTradingPage'; import PaperTradingPage from './components/PaperTradingPage';
import VirtualTradingPage from './components/VirtualTradingPage'; import VirtualTradingPage from './components/VirtualTradingPage';
import TrendSearchPage from './components/TrendSearchPage';
import DashboardPage from './components/DashboardPage'; import DashboardPage from './components/DashboardPage';
import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi'; import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi';
import ChartLegendBar from './components/ChartLegendBar'; import ChartLegendBar from './components/ChartLegendBar';
@@ -85,6 +86,7 @@ import {
type BacktestSignal, type BacktestSignal,
} from './utils/backendApi'; } from './utils/backendApi';
import { useVirtualLiveStrategy } from './hooks/useVirtualLiveStrategy'; import { useVirtualLiveStrategy } from './hooks/useVirtualLiveStrategy';
import type { ChartRealtimeSource } from './hooks/useChartRealtimeData';
import { VIRTUAL_SESSION_CHANGED_EVENT } from './utils/virtualTradingStorage'; import { VIRTUAL_SESSION_CHANGED_EVENT } from './utils/virtualTradingStorage';
import { notifyPaperTradesChanged } from './utils/paperTradeEvents'; import { notifyPaperTradesChanged } from './utils/paperTradeEvents';
import { useIsMobile } from './hooks/useMediaQuery'; import { useIsMobile } from './hooks/useMediaQuery';
@@ -1628,6 +1630,15 @@ function App() {
/> />
)} )}
{menuPage === 'trend-search' && (
<TrendSearchPage
theme={theme}
chartRealtimeSource={(appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP') as ChartRealtimeSource}
chartSeriesPriceLabels={appDefaults.chartSeriesPriceLabels}
tickers={marketTickers}
/>
)}
{/* ── 설정 화면 ──────────────────────────────────────────────────── */} {/* ── 설정 화면 ──────────────────────────────────────────────────── */}
{menuPage === 'notifications' && ( {menuPage === 'notifications' && (
<TradeNotificationListPage <TradeNotificationListPage
@@ -1993,6 +2004,7 @@ function App() {
chartVolumeVisible={chartVolumeVisible} chartVolumeVisible={chartVolumeVisible}
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
displayTimezone={displayTimezone} displayTimezone={displayTimezone}
compactMode
/> />
</div> </div>
{/* 슬롯 1~7 */} {/* 슬롯 1~7 */}
@@ -2028,6 +2040,7 @@ function App() {
chartVolumeVisible={chartVolumeVisible} chartVolumeVisible={chartVolumeVisible}
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
displayTimezone={displayTimezone} displayTimezone={displayTimezone}
compactMode
/> />
</div> </div>
))} ))}
+126 -24
View File
@@ -20,6 +20,9 @@ import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from '../utils/sma
import { normalizeIchimokuConfig } from '../utils/ichimokuConfig'; import { normalizeIchimokuConfig } from '../utils/ichimokuConfig';
import { isUpbitMarket } from '../utils/upbitApi'; import { isUpbitMarket } from '../utils/upbitApi';
import { useChartRealtimeData, type WsStatus } from '../hooks/useChartRealtimeData'; 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 { invalidateMarketCache } from '../utils/requestCache';
import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader'; import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader';
import { useIndicatorSettings } from '../hooks/useIndicatorSettings'; 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']; 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 배지 ─────────────────────────────────────────────────────────────── // ── Ws 배지 ───────────────────────────────────────────────────────────────
const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => { const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => {
if (!isUpbit) return null; if (!isUpbit) return null;
@@ -139,6 +160,8 @@ export interface ChartSlotProps {
chartVolumeVisible?: boolean; chartVolumeVisible?: boolean;
chartRealtimeSource?: 'BACKEND_STOMP' | 'UPBIT_DIRECT'; chartRealtimeSource?: 'BACKEND_STOMP' | 'UPBIT_DIRECT';
displayTimezone?: string; displayTimezone?: string;
/** 멀티차트 — 가상투자 카드 차트처럼 컴팩트 카드 UI */
compactMode?: boolean;
} }
const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot({ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot({
@@ -159,6 +182,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
chartVolumeVisible = true, chartVolumeVisible = true,
chartRealtimeSource = 'BACKEND_STOMP', chartRealtimeSource = 'BACKEND_STOMP',
displayTimezone, displayTimezone,
compactMode = false,
}, ref) { }, ref) {
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ──────────────────────── // ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings(); const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
@@ -489,6 +513,13 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
const dailyChange = currentBar ? currentBar.close - prevClose : 0; const dailyChange = currentBar ? currentBar.close - prevClose : 0;
const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 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) => { const handleIndicatorSave = useCallback((updated: IndicatorConfig) => {
setIndicators(prev => prev.map(i => i.id === updated.id ? updated : i)); setIndicators(prev => prev.map(i => i.id === updated.id ? updated : i));
@@ -564,20 +595,89 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
setShowMarket(false); setShowMarket(false);
}, []); }, []);
const displayKoName = compactKoName(symbol);
const trendUp = compactMode && currentBar != null && dailyChangePct >= 0;
const trendDown = compactMode && currentBar != null && dailyChangePct < 0;
return ( return (
<div <div
className={`chart-slot${active ? ' active' : ''}`} className={[
'chart-slot',
active ? 'active' : '',
compactMode ? 'chart-slot--compact' : '',
receiving ? 'chart-slot--receiving' : '',
trendUp ? 'chart-slot--trend-up' : '',
trendDown ? 'chart-slot--trend-down' : '',
].filter(Boolean).join(' ')}
onClick={onActivate} onClick={onActivate}
> >
{/* ── 슬롯 미니 헤더 ────────────────────────────────────────────────── */} {/* ── 슬롯 헤더 ──────────────────────────────────────────────────────── */}
<div className="slot-header" onClick={e => e.stopPropagation()}> <div
{/* 심볼 버튼: 클릭 시 슬롯 영역 rect를 캡처 후 MarketSearchPanel Portal 오픈 */} className={`slot-header${compactMode ? ' slot-header--compact' : ''}`}
onClick={e => e.stopPropagation()}
>
{compactMode ? (
<div className="slot-compact-row">
<button
type="button"
className={`slot-compact-name${showMarket ? ' open' : ''}`}
title={`${displayKoName} — 종목 변경`}
onClick={() => {
if (!showMarket) {
const slotEl = slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null;
setSlotAnchorRect(slotEl?.getBoundingClientRect() ?? null);
}
setShowMarket(v => !v);
}}
>
{displayKoName}
</button>
{currentBar && (
<div className="slot-compact-quote" aria-label="현재가">
<span
className="slot-compact-price"
style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}
>
{formatPrice(currentBar.close)}
</span>
<span
className="slot-compact-change"
style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}
>
{isUp ? '+' : '-'}{Math.abs(dailyChangePct).toFixed(2)}%
</span>
</div>
)}
<label className="slot-compact-tf" onClick={e => e.stopPropagation()}>
<span className="slot-compact-tf-label"></span>
<select
className="slot-compact-tf-select"
value={timeframe}
aria-label="시간봉 선택"
onChange={e => setTimeframe(e.target.value as Timeframe)}
>
{TF_OPTIONS.map(tf => (
<option key={tf} value={tf}>{tf}</option>
))}
</select>
</label>
<div className="slot-compact-status">
{isLoading && <span className="slot-loading"></span>}
{useUpbit && (
<VirtualLiveBadge status={wsToLiveStatus(wsStatus)} receiving={receiving} />
)}
</div>
</div>
) : (
<>
<button <button
className={`slot-sym-btn${showMarket ? ' open' : ''}`} className={`slot-sym-btn${showMarket ? ' open' : ''}`}
title="종목 변경" title="종목 변경"
onClick={() => { onClick={() => {
if (!showMarket) { if (!showMarket) {
// 클릭 시점에 슬롯 전체(.chart-slot) 컨테이너의 rect를 캡처
const slotEl = slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null; const slotEl = slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null;
setSlotAnchorRect(slotEl?.getBoundingClientRect() ?? null); setSlotAnchorRect(slotEl?.getBoundingClientRect() ?? null);
} }
@@ -592,19 +692,6 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
: <span className="slot-sym-kr">{symbol}</span> : <span className="slot-sym-kr">{symbol}</span>
} }
</button> </button>
{/* MarketSearchPanel: body 포털로 렌더 + anchorRect로 해당 슬롯 영역 안에 표시 */}
{showMarket && ReactDOM.createPortal(
<MarketSearchPanel
currentMarket={symbol}
onSelect={s => { handleSymbolSelect(s); }}
onClose={() => setShowMarket(false)}
anchorRect={slotAnchorRect}
/>,
document.body,
)}
{/* 타임프레임 선택 */}
<div className="slot-tf-row"> <div className="slot-tf-row">
{TF_OPTIONS.map(tf => ( {TF_OPTIONS.map(tf => (
<button <button
@@ -616,8 +703,6 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
</button> </button>
))} ))}
</div> </div>
{/* 현재가 */}
{currentBar && ( {currentBar && (
<span className="slot-price" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}> <span className="slot-price" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
{formatPrice(currentBar.close)} {formatPrice(currentBar.close)}
@@ -626,21 +711,36 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
</span> </span>
</span> </span>
)} )}
{useUpbit && <WsBadge status={wsStatus} isUpbit={true} />} {useUpbit && <WsBadge status={wsStatus} isUpbit={true} />}
{isLoading && <span className="slot-loading">...</span>} {isLoading && <span className="slot-loading">...</span>}
</>
)}
{showMarket && ReactDOM.createPortal(
<MarketSearchPanel
currentMarket={symbol}
onSelect={s => { handleSymbolSelect(s); }}
onClose={() => setShowMarket(false)}
anchorRect={slotAnchorRect}
/>,
document.body,
)}
</div> </div>
{/* ── TradingChart ─────────────────────────────────────────────────── */} {/* ── TradingChart ─────────────────────────────────────────────────── */}
<div <div
ref={slotContainerRef} ref={slotContainerRef}
className="slot-chart-wrap" className={compactMode ? 'vtd-card-chart-canvas slot-chart-wrap slot-chart-wrap--compact' : 'slot-chart-wrap'}
style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden', display: 'flex', flexDirection: 'column' }} style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}
> >
{useUpbit && isLoading && ( {useUpbit && isLoading && (
compactMode ? (
<div className="vtd-card-chart-loading"> </div>
) : (
<div className="chart-loading" style={{ position: 'absolute', zIndex: 10 }}> <div className="chart-loading" style={{ position: 'absolute', zIndex: 10 }}>
<div className="loading-spinner" /> <div className="loading-spinner" />
</div> </div>
)
)} )}
{isLoadingMore && ( {isLoadingMore && (
<div className="chart-history-loading"> <div className="chart-history-loading">
@@ -663,8 +763,10 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
drawingTool="cursor" drawingTool="cursor"
drawings={drawings} drawings={drawings}
logScale={false} logScale={false}
drawingsLocked={false} drawingsLocked={compactMode}
drawingsVisible={true} drawingsVisible={!compactMode}
showHoverToolbar={!compactMode}
volumeVisible={chartVolumeVisible}
onCrosshair={data => { onCrosshair={data => {
setLegend(data); setLegend(data);
if (data && onCrosshairTime) onCrosshairTime(data.time ?? 0); if (data && onCrosshairTime) onCrosshairTime(data.time ?? 0);
+10 -1
View File
@@ -11,7 +11,7 @@ import TradeAlertPopupMenubarSelect from './TradeAlertPopupMenubarSelect';
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout'; import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
import { canAccessMenu } from '../utils/permissions'; 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 { interface TopMenuBarProps {
activePage: MenuPage; activePage: MenuPage;
@@ -137,11 +137,20 @@ const IcVirtual = () => (
</svg> </svg>
); );
const IcTrendSearch = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="7" cy="7" r="4.5"/>
<path d="M10.5 10.5L14 14"/>
<path d="M5 7h4M7 5v4"/>
</svg>
);
const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [ const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
{ page: 'dashboard', label: '대시보드', icon: <IcDashboard /> }, { page: 'dashboard', label: '대시보드', icon: <IcDashboard /> },
{ page: 'chart', label: '실시간차트', icon: <IcChart /> }, { page: 'chart', label: '실시간차트', icon: <IcChart /> },
{ page: 'paper', label: '모의투자', icon: <IcPaper /> }, { page: 'paper', label: '모의투자', icon: <IcPaper /> },
{ page: 'virtual', label: '가상투자', icon: <IcVirtual /> }, { page: 'virtual', label: '가상투자', icon: <IcVirtual /> },
{ page: 'trend-search', label: '추세검색', icon: <IcTrendSearch /> },
{ page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> }, { page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> },
{ page: 'backtest', label: '백테스팅', icon: <IcBacktest /> }, { page: 'backtest', label: '백테스팅', icon: <IcBacktest /> },
{ page: 'settings', label: '설정', icon: <IcSettings /> }, { page: 'settings', label: '설정', icon: <IcSettings /> },
+151
View File
@@ -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<string, TickerData>;
}
const TrendSearchPage: React.FC<Props> = ({
theme = 'dark',
chartRealtimeSource = 'BACKEND_STOMP',
chartSeriesPriceLabels = true,
tickers,
}) => {
const [filters, setFilters] = useState<TrendSearchRequest>(() => ({ ...DEFAULT_TREND_SEARCH_REQUEST }));
const [results, setResults] = useState<TrendSearchResultDto[]>([]);
const [selectedMarket, setSelectedMarket] = useState<string | null>(null);
const [searching, setSearching] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(true);
const [displayMode, setDisplayMode] = useState<TrendSearchDisplayMode>(() => loadDisplayMode());
const [flashMarkets, setFlashMarkets] = useState<Set<string>>(new Set());
const [lastUpdatedAt, setLastUpdatedAt] = useState<number>(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 (
<BuilderPageShell
theme={theme}
title="추세검색"
subtitle="Golden Analysis · Trend Search"
pageClassName="bps-page--tsd bps-page--vtd"
headerActions={(
<TrendSearchViewHeaderControls
displayMode={displayMode}
onDisplayModeChange={setDisplayMode}
autoRefresh={autoRefresh}
onAutoRefreshChange={setAutoRefresh}
searching={searching}
onRefresh={() => void runSearch()}
resultCount={results.length}
/>
)}
leftStorageKey="tsd-left-width"
leftDefaultWidth={320}
leftCollapsedStorageKey="tsd-left-open"
collapsiblePanels
left={(
<TrendSearchFilterPanel
filters={filters}
onChange={setFilters}
onSearch={() => void runSearch()}
searching={searching}
/>
)}
center={(
<TrendSearchResultsCardGrid
results={results}
timeframe={filters.timeframe}
displayMode={displayMode}
loading={searching}
selectedMarket={selectedMarket}
onSelect={row => void handleSelect(row)}
flashMarkets={flashMarkets}
theme={theme}
chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels}
tickers={tickers}
lastUpdatedAt={lastUpdatedAt}
/>
)}
/>
);
};
export default TrendSearchPage;
@@ -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<Props> = ({
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<ChartManager | null>(null);
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
const [chartReloadTick, setChartReloadTick] = useState(0);
const chartReloadTriggeredRef = useRef(false);
const pendingBarRef = useRef<OHLCVBar | null>(null);
const barsMarketRef = useRef<string | null>(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 (
<div className="vtd-card-chart-wrap">
<div ref={canvasWrapRef} className="vtd-card-chart slot-chart-wrap">
{isLoading && <div className="vtd-card-chart-loading"> </div>}
{isLoadingMore && (
<div className="chart-history-loading">
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
<span> </span>
</div>
)}
<TradingChart
key={`${market}-${chartTf}-${chartReloadTick}`}
bars={bars}
barsMarket={barsMarket}
market={market}
timeframe={chartTf}
chartType={'candlestick' as ChartType}
theme={theme}
mode={'chart' as ChartMode}
indicators={indicators}
drawingTool="cursor"
drawings={[] as Drawing[]}
logScale={false}
drawingsLocked
drawingsVisible={false}
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
onCrosshair={noop as (d: LegendData | null) => void}
onManagerReady={mgr => { managerRef.current = mgr; }}
onAddDrawing={noop as (d: Drawing) => void}
onCandlesReady={handleCandlesReady}
magnifierEnabled={false}
volumeVisible
showHoverToolbar={false}
seriesPriceLabelsEnabled={chartSeriesPriceLabels}
/>
</div>
</div>
);
};
export default TrendSearchCardChart;
@@ -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<Props> = ({
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 <p className="vtd-muted vtd-card-empty"> </p>;
}
const updatedLabel = updatedAt
? new Date(updatedAt).toLocaleTimeString('ko-KR', {
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
})
: '—';
return (
<div className="vtd-sig-panel-wrap vtd-sig-panel-wrap--detail">
<div className="vtd-sig-panel vtd-sig-panel--detail-top">
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE &amp; MATCH RATES</div>
<div className="vtd-sig-visual">
<div className="vtd-sig-visual-eq">
<VirtualSignalEqualizer matchRate={resolvedMatch} />
</div>
<VirtualConditionList metrics={metrics} />
<VirtualSignalTrafficLight state={trafficState} matchRate={resolvedMatch} />
</div>
</div>
<div className="vtd-sig-detail-table" aria-label="지표별 상태 목록">
<VirtualIndicatorCompareTable metrics={metrics} />
</div>
<div className="vtd-card-foot">
<span className="vtd-card-updated"> {updatedLabel}</span>
<span className="vtd-card-match-summary">
{matchedCount}/{totalConditions} · {resolvedMatch}%
</span>
</div>
</div>
);
};
export default TrendSearchCardSignalPanel;
@@ -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<Props> = ({
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<ChartManager | null>(null);
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
const [chartReloadTick, setChartReloadTick] = useState(0);
const chartReloadTriggeredRef = useRef(false);
const pendingBarRef = useRef<OHLCVBar | null>(null);
const barsMarketRef = useRef<string | null>(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 (
<div className="tsd-chart-panel">
<div className="tsd-chart-toolbar">
<span className="tsd-chart-toolbar-title"> </span>
<div className="tsd-chart-tools" aria-label="차트 도구">
{['↖', '', '⌇', '□', 'T', '📷'].map((icon, i) => (
<button key={i} type="button" className="tsd-chart-tool-btn" title="차트 도구">{icon}</button>
))}
</div>
</div>
<div ref={canvasWrapRef} className="tsd-chart-body slot-chart-wrap">
{isLoading && <div className="tsd-chart-loading"> </div>}
{isLoadingMore && (
<div className="chart-history-loading">
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
<span> </span>
</div>
)}
<TradingChart
key={`${market}-${chartTf}-${chartReloadTick}`}
bars={bars}
barsMarket={barsMarket}
market={market}
timeframe={chartTf}
chartType={'candlestick' as ChartType}
theme={theme}
mode={'chart' as ChartMode}
indicators={indicators}
drawingTool="cursor"
drawings={[] as Drawing[]}
logScale={false}
drawingsLocked
drawingsVisible={false}
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
onCrosshair={noop as (d: LegendData | null) => void}
onManagerReady={mgr => { managerRef.current = mgr; }}
onAddDrawing={noop as (d: Drawing) => void}
onCandlesReady={handleCandlesReady}
magnifierEnabled={false}
volumeVisible
showHoverToolbar={false}
seriesPriceLabelsEnabled={chartSeriesPriceLabels}
/>
</div>
</div>
);
};
export default TrendSearchChartPanel;
@@ -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 <span className="tsd-cond-badge tsd-cond-badge--filter"></span>;
if (mode === 'unsupported') return <span className="tsd-cond-badge tsd-cond-badge--na"></span>;
return <span className="tsd-cond-badge tsd-cond-badge--sort"></span>;
}
const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch, searching }) => {
const patch = (p: Partial<TrendSearchRequest>) => 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 (
<div className="tsd-filter-panel">
<div className="tsd-panel-head">
<span className="tsd-panel-icon"></span>
<div>
<h3 className="tsd-panel-title"> </h3>
<p className="tsd-panel-sub">20 · + </p>
</div>
</div>
{TREND_CATEGORY_ORDER.map(cat => {
const items = TREND_CONDITION_DEFS.filter(c => c.category === cat.cat);
const catOn = filters[cat.key];
return (
<div key={cat.key} className={`tsd-filter-cat${catOn ? ' tsd-filter-cat--on' : ''}`}>
<div className="tsd-filter-cat-head">
<span className="tsd-filter-cat-ko">{cat.label}</span>
<label className="tsd-toggle">
<input
type="checkbox"
checked={catOn}
onChange={e => patch({ [cat.key]: e.target.checked })}
/>
<span className="tsd-toggle-track" />
</label>
</div>
{catOn && (
<div className="tsd-filter-items">
{items.map(item => {
const checked = Boolean(filters[item.requestKey]);
const disabled = item.mode === 'unsupported';
return (
<div key={item.id} className={`tsd-filter-item${disabled ? ' tsd-filter-item--disabled' : ''}`}>
<div className="tsd-filter-item-row">
<label className="tsd-filter-item-check">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={e => handleToggleCondition(item.requestKey, e.target.checked, item.id, item.mode)}
/>
<span className="tsd-filter-item-label">{item.label}</span>
{modeBadge(item.mode)}
</label>
{!disabled && (
<span className="tsd-filter-item-desc-inline" title={item.desc}>
{item.desc}
</span>
)}
</div>
{item.id === 'newHighCount' && checked && (
<div className="tsd-filter-params">
<span>N=</span>
<input
type="number"
className="tsd-num"
value={filters.newHighDays}
min={5}
max={60}
onChange={e => patch({ newHighDays: Number(e.target.value) })}
/>
<span></span>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
<div className="tsd-filter-section">
<h4 className="tsd-filter-section-title"> </h4>
<select
className="tsd-sort-select"
value={sortOptions.some(o => o.id === filters.sortBy) ? filters.sortBy : sortOptions[0]?.id ?? 'near52wHigh'}
onChange={e => patch({ sortBy: e.target.value })}
>
{sortOptions.length === 0 && <option value="near52wHigh">52 </option>}
{sortOptions.map(o => (
<option key={o.id} value={o.id}>{o.label}</option>
))}
</select>
</div>
<div className="tsd-filter-section">
<h4 className="tsd-filter-section-title"> </h4>
<div className="tsd-tf-grid">
{TREND_TIMEFRAMES.map(tf => (
<button
key={tf}
type="button"
className={`tsd-tf-btn${filters.timeframe === tf ? ' tsd-tf-btn--on' : ''}`}
onClick={() => patch({ timeframe: tf })}
>
{tfLabel(tf)}
</button>
))}
</div>
</div>
<div className="tsd-filter-section">
<h4 className="tsd-filter-section-title"> </h4>
<div className="tsd-limit-row">
<input
type="range"
className="tsd-slider"
min={5}
max={50}
value={filters.limit}
onChange={e => patch({ limit: Number(e.target.value) })}
/>
<span className="tsd-limit-val">{filters.limit}</span>
</div>
</div>
<button type="button" className="tsd-search-btn" disabled={searching} onClick={onSearch}>
{searching ? '스캔 중…' : '조건 검색 실행'}
</button>
</div>
);
};
export default TrendSearchFilterPanel;
@@ -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<Props> = ({
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 (
<div
className={[
'vtd-card',
'vtd-card--signal',
'vtd-card--detail',
isChart ? 'vtd-card--chart-mode' : '',
selected ? 'vtd-card--selected' : '',
flashCls,
].filter(Boolean).join(' ')}
onClick={onSelect}
onKeyDown={e => {
if (onSelect && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onSelect();
}
}}
role={onSelect ? 'button' : undefined}
tabIndex={onSelect ? 0 : undefined}
aria-pressed={onSelect ? selected : undefined}
>
<div className="vtd-card-head">
<div className="vtd-card-head-main">
<div className="vtd-card-title">
<span className="vtd-card-ko">{ko}</span>
<span className="vtd-card-sym">{sym}</span>
</div>
<span className="vtd-card-tf"> {tfLabelShort(timeframe)}</span>
{result.highMatch && <span className="tsd-card-high-badge">HIGH MATCH</span>}
</div>
</div>
<div className="vtd-card-meta">
<VirtualTargetQuote market={result.market} ticker={quoteTicker} compact />
{!isChart && (
<span className="vtd-card-met-count">
{result.matchedCount}/{result.totalConditions}
</span>
)}
<VirtualLiveBadge status="live" receiving={flash} />
</div>
{isChart ? (
<TrendSearchCardChart
market={result.market}
timeframe={timeframe}
theme={theme}
chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels}
/>
) : (
<TrendSearchCardSignalPanel
conditions={result.conditions}
matchRate={result.matchRate}
matchedCount={result.matchedCount}
totalConditions={result.totalConditions}
updatedAt={updatedAt}
/>
)}
</div>
);
};
export default TrendSearchResultCard;
@@ -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<string>;
theme?: Theme;
chartRealtimeSource?: ChartRealtimeSource;
chartSeriesPriceLabels?: boolean;
tickers?: Map<string, TickerData>;
lastUpdatedAt?: number;
}
const TrendSearchResultsCardGrid: React.FC<Props> = ({
results,
timeframe,
displayMode,
loading,
selectedMarket,
onSelect,
flashMarkets,
theme = 'dark',
chartRealtimeSource = 'BACKEND_STOMP',
chartSeriesPriceLabels = true,
tickers,
lastUpdatedAt,
}) => {
if (loading && results.length === 0) {
return (
<div className="vtd-grid-wrap">
<div className="vtd-grid-empty">
<p className="vtd-muted"> </p>
</div>
</div>
);
}
if (!loading && results.length === 0) {
return (
<div className="vtd-grid-wrap">
<div className="vtd-grid-empty">
<p className="vtd-muted"> . .</p>
</div>
</div>
);
}
return (
<div className="vtd-grid-wrap">
<div className={`vtd-grid${displayMode === 'chart' ? ' vtd-grid--chart-mode' : ''}`}>
{results.map(row => (
<TrendSearchResultCard
key={row.market}
result={row}
timeframe={timeframe}
displayMode={displayMode}
selected={selectedMarket === row.market}
onSelect={onSelect ? () => onSelect(row) : undefined}
theme={theme}
chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels}
ticker={tickers?.get(row.market)}
updatedAt={lastUpdatedAt}
flash={flashMarkets?.has(row.market)}
/>
))}
</div>
</div>
);
};
export default TrendSearchResultsCardGrid;
@@ -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<string>;
}
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<Props> = ({
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 (
<div className="tsd-results">
<div className="tsd-results-head">
<div>
<h3 className="tsd-panel-title"> </h3>
<p className="tsd-panel-sub">Search Condition Match Rate % Sorting · 100ms </p>
</div>
<div className="tsd-results-meta">
<span className="tsd-meta-chip"> {avgMatch}%</span>
<span className="tsd-meta-chip tsd-meta-chip--live"> LIVE</span>
</div>
</div>
<div className="tsd-results-table-wrap">
<table className="tsd-results-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{loading && results.length === 0 && (
<tr><td colSpan={5} className="tsd-empty"> </td></tr>
)}
{!loading && results.length === 0 && (
<tr><td colSpan={5} className="tsd-empty"> . .</td></tr>
)}
{results.map(row => {
const up = row.changeRate >= 0;
const active = selectedMarket === row.market;
const flash = flashMarkets?.has(row.market);
return (
<tr
key={row.market}
className={[
active ? 'tsd-row--sel' : '',
flash ? (up ? 'tsd-row--flash-up' : 'tsd-row--flash-down') : '',
].filter(Boolean).join(' ')}
onClick={() => onSelect(row)}
>
<td className="tsd-col-symbol">
<span className="tsd-symbol">{coinLabel(row.market, row.koreanName)}</span>
</td>
<td className="tsd-col-price">{fmtPrice(row.currentPrice)}</td>
<td className={`tsd-col-change${up ? ' up' : ' down'}`}>{fmtPct(row.changeRate)}</td>
<td className="tsd-col-match">
<div className="tsd-match-bar-wrap">
<div className="tsd-match-bar">
<div className="tsd-match-bar-fill" style={{ width: `${row.matchRate}%` }} />
</div>
<span className="tsd-match-pct">{row.matchRate}%</span>
</div>
</td>
<td className="tsd-col-badge">
{row.highMatch && <span className="tsd-badge-high">HIGH MATCH</span>}
{!row.highMatch && row.matchRate >= 70 && <span className="tsd-badge-mid">MATCH</span>}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
};
export default TrendSearchResultsGrid;
@@ -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<Props> = ({
conditions,
matchRate,
matchedCount,
totalConditions,
}) => (
<div className="tsd-signal-panel">
<div className="tsd-signal-head">
<div className="tsd-signal-intel">
<span className="tsd-signal-label">Signal Intelligence</span>
<span className="tsd-signal-count">{matchedCount}/{totalConditions} </span>
</div>
<VirtualSignalTrafficLight state={resolveTraffic(matchRate, conditions)} matchRate={matchRate} />
</div>
<div className="tsd-compare-wrap">
<h4 className="tsd-compare-title">REAL-TIME INDICATOR COMPARISON</h4>
<table className="tsd-compare-table">
<thead>
<tr>
<th>TECHNICAL INDICATOR</th>
<th>CURRENT</th>
<th>THRESHOLD</th>
<th>PROGRESS</th>
<th>STATUS</th>
</tr>
</thead>
<tbody>
{conditions.length === 0 ? (
<tr><td colSpan={5} className="tsd-empty"> </td></tr>
) : conditions.map(c => (
<tr key={c.id} className={`tsd-compare-row--${c.status}`}>
<td>{c.label}</td>
<td>{c.currentValue != null ? c.currentValue.toLocaleString() : '—'}</td>
<td>{c.thresholdLabel}</td>
<td>
<div className="tsd-progress-cell">
<div className="tsd-progress-bar">
<div
className={`tsd-progress-fill tsd-progress-fill--${c.status}`}
style={{ width: `${c.progress}%` }}
/>
</div>
<span>{c.progress}%</span>
</div>
</td>
<td>
<span className={`tsd-status tsd-status--${c.status}`}>
{c.status === 'match' ? 'Match' : c.status === 'pending' ? 'Pending' : 'Mismatch'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
export default TrendSearchSignalPanel;
@@ -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<Props> = ({
displayMode,
onDisplayModeChange,
autoRefresh,
onAutoRefreshChange,
searching,
onRefresh,
resultCount = 0,
}) => (
<div className="vtd-header-view tsd-header-view">
<div className="vtd-grid-head-group" role="group" aria-label="검색 결과 표시">
<div className="vtd-view-toggle">
<button
type="button"
className={`vtd-view-toggle-btn${displayMode === 'summary' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onDisplayModeChange('summary')}
>
</button>
<button
type="button"
className={`vtd-view-toggle-btn${displayMode === 'chart' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onDisplayModeChange('chart')}
>
</button>
</div>
</div>
<span className="vtd-grid-head-divider" aria-hidden="true" />
<span className="tsd-result-count">{resultCount}</span>
<span className="vtd-grid-head-divider" aria-hidden="true" />
<label className="tsd-auto-refresh">
<input type="checkbox" checked={autoRefresh} onChange={e => onAutoRefreshChange(e.target.checked)} />
<span>3 </span>
</label>
<button type="button" className="tsd-refresh-btn" disabled={searching} onClick={onRefresh}>
</button>
</div>
);
export default TrendSearchViewHeaderControls;
@@ -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);
}
}
+14 -3
View File
@@ -24,10 +24,17 @@ import { IndicatorFillPrimitive } from './IndicatorFillPrimitive';
import { BollingerBandFillPrimitive } from './BollingerBandFillPrimitive'; import { BollingerBandFillPrimitive } from './BollingerBandFillPrimitive';
import { resolveBbBandBackground } from './bollingerConfig'; import { resolveBbBandBackground } from './bollingerConfig';
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, Timeframe } from '../types'; import type { OHLCVBar, ChartType, Theme, IndicatorConfig, Timeframe } from '../types';
import { formatChartAxisPrice } from './dataGenerator';
import { formatLwcTime, formatLwcTickMark, DEFAULT_DISPLAY_TIMEZONE } from './timezone'; import { formatLwcTime, formatLwcTickMark, DEFAULT_DISPLAY_TIMEZONE } from './timezone';
import { sortIndicatorsForPaneLoad } from './indicatorPaneMerge'; import { sortIndicatorsForPaneLoad } from './indicatorPaneMerge';
import { calculateIndicator, enrichIndicatorConfig, getIndicatorDef, getHLineLabel, type PlotData, type MarkerData } from './indicatorRegistry'; import { calculateIndicator, enrichIndicatorConfig, getIndicatorDef, getHLineLabel, type PlotData, type MarkerData } from './indicatorRegistry';
import { IchimokuCloudPlugin, type IchimokuCloudPoint } from './IchimokuCloudPlugin'; import { IchimokuCloudPlugin, type IchimokuCloudPoint } from './IchimokuCloudPlugin';
const MAIN_PRICE_FORMAT = {
type: 'custom' as const,
minMove: 0.001,
formatter: formatChartAxisPrice,
};
import { import {
getIchimokuPlotTitle, getIchimokuPlotTitle,
isIchimokuCloudVisible, isIchimokuCloudVisible,
@@ -378,26 +385,30 @@ export class ChartManager {
upColor: t.upColor, downColor: t.downColor, upColor: t.upColor, downColor: t.downColor,
borderUpColor: t.upColor, borderDownColor: t.downColor, borderUpColor: t.upColor, borderDownColor: t.downColor,
wickUpColor: t.upColor, wickDownColor: t.downColor, wickUpColor: t.upColor, wickDownColor: t.downColor,
priceFormat: MAIN_PRICE_FORMAT,
}); });
case 'bar': 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': 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': case 'area':
return this.chart.addSeries(AreaSeries, { return this.chart.addSeries(AreaSeries, {
lineColor: '#2962FF', topColor: '#2962FF44', bottomColor: '#2962FF00', lineWidth: 2, lineColor: '#2962FF', topColor: '#2962FF44', bottomColor: '#2962FF00', lineWidth: 2,
priceFormat: MAIN_PRICE_FORMAT,
}); });
case 'baseline': case 'baseline':
return this.chart.addSeries(BaselineSeries, { return this.chart.addSeries(BaselineSeries, {
baseValue: { type: 'price', price: 0 }, baseValue: { type: 'price', price: 0 },
topLineColor: '#26a69a', topFillColor1: '#26a69a44', topFillColor2: '#26a69a00', topLineColor: '#26a69a', topFillColor1: '#26a69a44', topFillColor2: '#26a69a00',
bottomLineColor: '#ef5350', bottomFillColor1: '#ef535000', bottomFillColor2: '#ef535044', bottomLineColor: '#ef5350', bottomFillColor1: '#ef535000', bottomFillColor2: '#ef535044',
priceFormat: MAIN_PRICE_FORMAT,
}); });
default: default:
return this.chart.addSeries(CandlestickSeries, { return this.chart.addSeries(CandlestickSeries, {
upColor: t.upColor, downColor: t.downColor, upColor: t.upColor, downColor: t.downColor,
borderUpColor: t.upColor, borderDownColor: t.downColor, borderUpColor: t.upColor, borderDownColor: t.downColor,
wickUpColor: t.upColor, wickDownColor: t.downColor, wickUpColor: t.upColor, wickDownColor: t.downColor,
priceFormat: MAIN_PRICE_FORMAT,
}); });
} }
} }
@@ -419,7 +430,7 @@ export class ChartManager {
const timeFormatter = (time: Time) => const timeFormatter = (time: Time) =>
formatLwcTime(time as number | { year: number; month: number; day: number }, tf, tz); formatLwcTime(time as number | { year: number; month: number; day: number }, tf, tz);
this.chart.applyOptions({ this.chart.applyOptions({
localization: { timeFormatter }, localization: { timeFormatter, priceFormatter: formatChartAxisPrice },
timeScale: { tickMarkFormatter: this._tickMarkFormatter() }, timeScale: { tickMarkFormatter: this._tickMarkFormatter() },
}); });
} }
+109
View File
@@ -1124,3 +1124,112 @@ export async function pinCandleWatch(market: string, candleType: string): Promis
/* pin 실패해도 평가 API는 시도 */ /* 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<TrendSearchResultDto[]> {
return (await request<TrendSearchResultDto[]>('/trend-search/scan', {
method: 'POST',
body: JSON.stringify(body),
})) ?? [];
}
export async function fetchTrendSearchDetail(
market: string,
timeframe?: string,
): Promise<TrendSearchResultDto | null> {
const params = new URLSearchParams({ market });
if (timeframe) params.set('timeframe', timeframe);
return request<TrendSearchResultDto>(`/trend-search/detail?${params}`);
}
+9
View File
@@ -88,6 +88,15 @@ export function formatPrice(price: number): string {
return price.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 6 }); 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 { export function formatTime(ts: number, timeframe: Timeframe): string {
return formatUnixForChart(ts, timeframe); return formatUnixForChart(ts, timeframe);
} }
+3 -2
View File
@@ -12,7 +12,7 @@ export type SettingsCategoryId =
| 'paper' | 'alert' | 'network' | 'admin'; | 'paper' | 'alert' | 'network' | 'admin';
export const TOP_MENU_IDS: TopMenuId[] = [ 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[] = [ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
@@ -20,7 +20,7 @@ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
]; ];
export const ALL_MENU_IDS = [ 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), ...SETTINGS_MENU_IDS.map(id => `settings_${id}` as const),
] as const; ] as const;
@@ -31,6 +31,7 @@ export const MENU_LABELS: Record<string, string> = {
chart: '실시간차트', chart: '실시간차트',
paper: '모의투자', paper: '모의투자',
virtual: '가상투자', virtual: '가상투자',
'trend-search': '추세검색',
strategy: '투자전략', strategy: '투자전략',
'strategy-editor': '전략편집기', 'strategy-editor': '전략편집기',
backtest: '백테스팅', backtest: '백테스팅',
+20
View File
@@ -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);
}
+240
View File
@@ -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 },
];
+44
View File
@@ -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;
}