추세검색 화면 적용
This commit is contained in:
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 /> },
|
||||||
|
|||||||
@@ -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 & 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '백테스팅',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
];
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user