diff --git a/backend/src/main/java/com/goldenchart/config/TrendSearchSchedulerConfig.java b/backend/src/main/java/com/goldenchart/config/TrendSearchSchedulerConfig.java new file mode 100644 index 0000000..a620609 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/config/TrendSearchSchedulerConfig.java @@ -0,0 +1,29 @@ +package com.goldenchart.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +/** + * 추세검색 전용 단일 스레드 실행기 — 메인 API·라이브 전략 스케줄과 분리. + */ +@Configuration +public class TrendSearchSchedulerConfig { + + @Bean(name = "trendSearchExecutor") + public Executor trendSearchExecutor( + @Value("${goldenchart.trend-search.scheduler.queue-capacity:16}") int queueCapacity) { + ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor(); + ex.setThreadNamePrefix("trend-search-"); + ex.setCorePoolSize(1); + ex.setMaxPoolSize(1); + ex.setQueueCapacity(queueCapacity); + ex.setWaitForTasksToCompleteOnShutdown(true); + ex.setAwaitTerminationSeconds(30); + ex.initialize(); + return ex; + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/TrendSearchController.java b/backend/src/main/java/com/goldenchart/controller/TrendSearchController.java index 057c13d..0be1570 100644 --- a/backend/src/main/java/com/goldenchart/controller/TrendSearchController.java +++ b/backend/src/main/java/com/goldenchart/controller/TrendSearchController.java @@ -2,7 +2,9 @@ package com.goldenchart.controller; import com.goldenchart.dto.TrendSearchRequest; import com.goldenchart.dto.TrendSearchResultDto; +import com.goldenchart.dto.TrendSearchResultsResponse; import com.goldenchart.service.TrendSearchService; +import com.goldenchart.service.TrendSearchSnapshotService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; @@ -13,8 +15,9 @@ import java.util.List; /** * 암호화폐 추세검색 API. * - * POST /api/trend-search/scan — 조건 스캔 - * GET /api/trend-search/detail?market=KRW-BTC — 단일 종목 상세 + * POST /api/trend-search/scan — 조건 스캔 후 DB 스냅샷 갱신 + * GET /api/trend-search/results — DB 스냅샷 조회 (자동 갱신 화면용) + * GET /api/trend-search/detail — 단일 종목 상세 */ @RestController @RequestMapping("/trend-search") @@ -23,11 +26,24 @@ import java.util.List; public class TrendSearchController { private final TrendSearchService trendSearchService; + private final TrendSearchSnapshotService snapshotService; @PostMapping("/scan") - public ResponseEntity> scan(@RequestBody TrendSearchRequest request) { + public ResponseEntity> scan( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId, + @RequestBody TrendSearchRequest request) { log.info("[TrendSearch] scan tf={} limit={}", request.getTimeframe(), request.getLimit()); - return ResponseEntity.ok(trendSearchService.scan(request)); + Long userId = parseUserId(userIdHeader); + return ResponseEntity.ok(snapshotService.scanAndPersist(userId, deviceId, request)); + } + + @GetMapping("/results") + public ResponseEntity results( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + Long userId = parseUserId(userIdHeader); + return ResponseEntity.ok(snapshotService.getResults(userId, deviceId)); } @GetMapping("/detail") @@ -38,4 +54,13 @@ public class TrendSearchController { if (timeframe != null) req.setTimeframe(timeframe); return ResponseEntity.ok(trendSearchService.detail(market, req)); } + + private Long parseUserId(String header) { + if (header == null || header.isBlank()) return null; + try { + return Long.parseLong(header); + } catch (NumberFormatException e) { + return null; + } + } } diff --git a/backend/src/main/java/com/goldenchart/dto/TrendSearchResultsResponse.java b/backend/src/main/java/com/goldenchart/dto/TrendSearchResultsResponse.java new file mode 100644 index 0000000..4a9cfc5 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/TrendSearchResultsResponse.java @@ -0,0 +1,26 @@ +package com.goldenchart.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TrendSearchResultsResponse { + private List results; + private String timeframe; + private Instant scannedAt; + + public static TrendSearchResultsResponse empty() { + return TrendSearchResultsResponse.builder() + .results(Collections.emptyList()) + .build(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcTrendSearchSnapshot.java b/backend/src/main/java/com/goldenchart/entity/GcTrendSearchSnapshot.java new file mode 100644 index 0000000..7395a35 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcTrendSearchSnapshot.java @@ -0,0 +1,42 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "gc_trend_search_snapshot") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class GcTrendSearchSnapshot { + + @Id + @Column(name = "scope_key", length = 128, nullable = false) + private String scopeKey; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "device_id", length = 100) + private String deviceId; + + @Column(name = "timeframe", length = 16, nullable = false) + private String timeframe; + + @Column(name = "scanned_at", nullable = false) + private LocalDateTime scannedAt; + + @Column(name = "request_json", columnDefinition = "JSON") + @JdbcTypeCode(SqlTypes.JSON) + private String requestJson; + + @Column(name = "results_json", columnDefinition = "JSON", nullable = false) + @JdbcTypeCode(SqlTypes.JSON) + private String resultsJson; +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcAppSettingsRepository.java b/backend/src/main/java/com/goldenchart/repository/GcAppSettingsRepository.java index 3a4c32b..17d1e4b 100644 --- a/backend/src/main/java/com/goldenchart/repository/GcAppSettingsRepository.java +++ b/backend/src/main/java/com/goldenchart/repository/GcAppSettingsRepository.java @@ -4,6 +4,7 @@ import com.goldenchart.entity.GcAppSettings; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -12,4 +13,6 @@ public interface GcAppSettingsRepository extends JpaRepository findByDeviceId(String deviceId); Optional findByUserId(Long userId); + + List findAllByTrendSearchSettingsJsonIsNotNull(); } diff --git a/backend/src/main/java/com/goldenchart/repository/GcTrendSearchSnapshotRepository.java b/backend/src/main/java/com/goldenchart/repository/GcTrendSearchSnapshotRepository.java new file mode 100644 index 0000000..b45fa40 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcTrendSearchSnapshotRepository.java @@ -0,0 +1,9 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcTrendSearchSnapshot; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface GcTrendSearchSnapshotRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/goldenchart/service/TrendSearchBullishScoring.java b/backend/src/main/java/com/goldenchart/service/TrendSearchBullishScoring.java new file mode 100644 index 0000000..cfcfee3 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/TrendSearchBullishScoring.java @@ -0,0 +1,41 @@ +package com.goldenchart.service; + +import com.goldenchart.dto.TrendSearchRequest; + +/** + * 상승추세 검색그룹 가중 점수 — 단위 테스트·스캔 로직 공용. + */ +public final class TrendSearchBullishScoring { + + private TrendSearchBullishScoring() {} + + public interface BullishFlags { + boolean bullishEmaAlignment(); + boolean bullishEmaSlope(); + boolean bullishAdxTrend(); + boolean bullishMacdMomentum(); + boolean bullishPricePosition(); + } + + /** 조건별 배점 합산 (0~100 상한) */ + public static int computeScore(BullishFlags m, TrendSearchRequest r) { + int score = 0; + if (m.bullishEmaAlignment()) score += Math.max(0, r.getWeightMaAlignment()); + if (m.bullishEmaSlope()) score += Math.max(0, r.getWeightMaSlope()); + if (m.bullishAdxTrend()) score += Math.max(0, r.getWeightAdxTrend()); + if (m.bullishMacdMomentum()) score += Math.max(0, r.getWeightMacdMomentum()); + if (m.bullishPricePosition()) score += Math.max(0, r.getWeightPricePosition()); + return Math.min(100, score); + } + + /** 최소 합격 점수 필터 */ + public static boolean passesMinTrendScore(int score, TrendSearchRequest r) { + int min = Math.min(100, Math.max(0, r.getMinTrendScore())); + return min <= 0 || score >= min; + } + + /** 결과 카드 conditions 점수 합이 matchRate 와 일치하는지 */ + public static int sumEarnedFromWeights(BullishFlags m, TrendSearchRequest r) { + return computeScore(m, r); + } +} diff --git a/backend/src/main/java/com/goldenchart/service/TrendSearchSchedulerService.java b/backend/src/main/java/com/goldenchart/service/TrendSearchSchedulerService.java new file mode 100644 index 0000000..1f48675 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/TrendSearchSchedulerService.java @@ -0,0 +1,71 @@ +package com.goldenchart.service; + +import com.goldenchart.entity.GcAppSettings; +import com.goldenchart.repository.GcAppSettingsRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 추세검색 자동 갱신 — gc_app_settings 의 간격·ON 설정에 따라 별도 스레드에서 스캔. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class TrendSearchSchedulerService { + + private final GcAppSettingsRepository appSettingsRepo; + private final TrendSearchSnapshotService snapshotService; + private final TrendSearchSettingsHelper settingsHelper; + @Qualifier("trendSearchExecutor") + private final Executor trendSearchExecutor; + + @Value("${goldenchart.trend-search.scheduler.enabled:true}") + private boolean schedulerEnabled; + + private final ConcurrentHashMap inFlight = new ConcurrentHashMap<>(); + + @Scheduled(fixedDelayString = "${goldenchart.trend-search.scheduler.tick-ms:1000}") + public void tick() { + if (!schedulerEnabled) return; + + List rows = appSettingsRepo.findAllByTrendSearchSettingsJsonIsNotNull(); + LocalDateTime now = LocalDateTime.now(); + + for (GcAppSettings app : rows) { + Map ts = settingsHelper.parseSettingsJson(app.getTrendSearchSettingsJson()); + if (!settingsHelper.isAutoRefreshEnabled(ts)) continue; + + int intervalSec = settingsHelper.autoRefreshSeconds(ts); + Long userId = app.getUserId(); + String deviceId = app.getDeviceId(); + String scopeKey = TrendSearchScope.key(userId, deviceId); + + LocalDateTime last = snapshotService.lastScannedAt(userId, deviceId).orElse(LocalDateTime.MIN); + if (last.plusSeconds(intervalSec).isAfter(now)) continue; + + AtomicBoolean flag = inFlight.computeIfAbsent(scopeKey, k -> new AtomicBoolean(false)); + if (!flag.compareAndSet(false, true)) continue; + + trendSearchExecutor.execute(() -> { + try { + snapshotService.runScheduledScan(userId, deviceId); + } catch (Exception e) { + log.warn("[TrendSearch] scheduled scan failed scope={}: {}", scopeKey, e.getMessage()); + } finally { + flag.set(false); + } + }); + } + } +} diff --git a/backend/src/main/java/com/goldenchart/service/TrendSearchScope.java b/backend/src/main/java/com/goldenchart/service/TrendSearchScope.java new file mode 100644 index 0000000..38fea81 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/TrendSearchScope.java @@ -0,0 +1,15 @@ +package com.goldenchart.service; + +/** + * 추세검색 결과 스코프 (사용자 또는 기기). + */ +public final class TrendSearchScope { + + private TrendSearchScope() {} + + public static String key(Long userId, String deviceId) { + if (userId != null) return "u:" + userId; + String d = (deviceId != null && !deviceId.isBlank()) ? deviceId : "anonymous"; + return "d:" + d; + } +} diff --git a/backend/src/main/java/com/goldenchart/service/TrendSearchService.java b/backend/src/main/java/com/goldenchart/service/TrendSearchService.java index 2469826..a90fca1 100644 --- a/backend/src/main/java/com/goldenchart/service/TrendSearchService.java +++ b/backend/src/main/java/com/goldenchart/service/TrendSearchService.java @@ -91,7 +91,7 @@ public class TrendSearchService { if (useBullishScore) { m.bullishTrendScore = computeBullishTrendScore(m, r); m.matchScore = m.bullishTrendScore; - if (minTrendScore > 0 && m.bullishTrendScore < minTrendScore) continue; + if (!TrendSearchBullishScoring.passesMinTrendScore(m.bullishTrendScore, r)) continue; } else { m.trendStrength = computeTrendStrength(m); m.matchScore = enabledFilters == 0 @@ -365,13 +365,7 @@ public class TrendSearchService { /** 상승추세 검색그룹 — 가중 점수 합산 (0~100) */ private int computeBullishTrendScore(MetricValues m, TrendSearchRequest r) { - int score = 0; - if (m.bullishEmaAlignment) score += Math.max(0, r.getWeightMaAlignment()); - if (m.bullishEmaSlope) score += Math.max(0, r.getWeightMaSlope()); - if (m.bullishAdxTrend) score += Math.max(0, r.getWeightAdxTrend()); - if (m.bullishMacdMomentum) score += Math.max(0, r.getWeightMacdMomentum()); - if (m.bullishPricePosition) score += Math.max(0, r.getWeightPricePosition()); - return Math.min(100, score); + return TrendSearchBullishScoring.computeScore(m, r); } /** 정배열·상승세·거래량 복합 추세 강도 (0~100) */ @@ -830,7 +824,7 @@ public class TrendSearchService { private record ScoredMarket(String market, TickerSnap tick, List candles, MetricValues metrics) {} - private static class MetricValues { + private static class MetricValues implements TrendSearchBullishScoring.BullishFlags { final boolean priceAboveMa, maAlignment, maConvergence; final double maSpreadPct; final boolean ma20SlopeUp, newHighBreakout, ichimokuAboveCloud; @@ -895,5 +889,11 @@ public class TrendSearchService { this.bullishMacdMomentum = bullishMacdMomentum; this.bullishPricePosition = bullishPricePosition; } + + @Override public boolean bullishEmaAlignment() { return bullishEmaAlignment; } + @Override public boolean bullishEmaSlope() { return bullishEmaSlope; } + @Override public boolean bullishAdxTrend() { return bullishAdxTrend; } + @Override public boolean bullishMacdMomentum() { return bullishMacdMomentum; } + @Override public boolean bullishPricePosition() { return bullishPricePosition; } } } diff --git a/backend/src/main/java/com/goldenchart/service/TrendSearchSettingsHelper.java b/backend/src/main/java/com/goldenchart/service/TrendSearchSettingsHelper.java new file mode 100644 index 0000000..c03b0a1 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/TrendSearchSettingsHelper.java @@ -0,0 +1,116 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.dto.TrendSearchRequest; +import com.goldenchart.entity.GcAppSettings; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * gc_app_settings.trend_search_settings_json → 스캔 요청·스케줄 옵션. + */ +@Component +@RequiredArgsConstructor +public class TrendSearchSettingsHelper { + + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + + private final ObjectMapper mapper; + + public Map parseSettingsJson(String json) { + if (json == null || json.isBlank()) return Map.of(); + try { + return mapper.readValue(json, MAP_TYPE); + } catch (Exception e) { + return Map.of(); + } + } + + public boolean isAutoRefreshEnabled(Map settings) { + Object v = settings.get("autoRefreshEnabled"); + if (v == null) return true; + return Boolean.parseBoolean(v.toString()); + } + + public int autoRefreshSeconds(Map settings) { + int sec = intVal(settings.get("autoRefreshSeconds"), 3); + return switch (sec) { + case 3, 5, 10, 30, 60 -> sec; + default -> 3; + }; + } + + public TrendSearchRequest buildRequest(GcAppSettings app, TrendSearchRequest lastRequest) { + Map ts = parseSettingsJson(app.getTrendSearchSettingsJson()); + TrendSearchRequest req = lastRequest != null ? copyRequest(lastRequest) : new TrendSearchRequest(); + applyAppSettings(req, ts); + if (req.getTimeframe() == null || req.getTimeframe().isBlank()) { + req.setTimeframe(normalizeTimeframe(app.getDefaultTimeframe())); + } else { + req.setTimeframe(normalizeTimeframe(req.getTimeframe())); + } + return req; + } + + public TrendSearchRequest mergeRequestWithSettings(TrendSearchRequest body, GcAppSettings app) { + Map ts = parseSettingsJson(app.getTrendSearchSettingsJson()); + TrendSearchRequest req = body != null ? copyRequest(body) : new TrendSearchRequest(); + applyAppSettings(req, ts); + if (req.getTimeframe() != null) { + req.setTimeframe(normalizeTimeframe(req.getTimeframe())); + } + return req; + } + + private void applyAppSettings(TrendSearchRequest req, Map ts) { + if (ts.isEmpty()) return; + req.setWeightMaAlignment(intVal(ts.get("weightMaAlignment"), req.getWeightMaAlignment())); + req.setWeightMaSlope(intVal(ts.get("weightMaSlope"), req.getWeightMaSlope())); + req.setWeightAdxTrend(intVal(ts.get("weightAdxTrend"), req.getWeightAdxTrend())); + req.setWeightMacdMomentum(intVal(ts.get("weightMacdMomentum"), req.getWeightMacdMomentum())); + req.setWeightPricePosition(intVal(ts.get("weightPricePosition"), req.getWeightPricePosition())); + req.setMinTrendScore(intVal(ts.get("minTrendScore"), req.getMinTrendScore())); + req.setLimit(intVal(ts.get("limit"), req.getLimit())); + req.setScanLimit(intVal(ts.get("scanLimit"), req.getScanLimit())); + } + + private TrendSearchRequest copyRequest(TrendSearchRequest src) { + try { + return mapper.readValue(mapper.writeValueAsBytes(src), TrendSearchRequest.class); + } catch (Exception e) { + TrendSearchRequest r = new TrendSearchRequest(); + r.setTimeframe(src.getTimeframe()); + return r; + } + } + + public TrendSearchRequest parseRequestJson(String json) { + if (json == null || json.isBlank()) return null; + try { + return mapper.readValue(json, TrendSearchRequest.class); + } catch (Exception e) { + return null; + } + } + + static String normalizeTimeframe(String tf) { + if (tf == null || tf.isBlank()) return "1d"; + String t = tf.trim(); + if (t.length() == 2 && Character.isDigit(t.charAt(0))) { + return t.charAt(0) + t.substring(1).toLowerCase(); + } + return t.toLowerCase(); + } + + private static int intVal(Object v, int fallback) { + if (v == null) return fallback; + try { + return Integer.parseInt(v.toString()); + } catch (NumberFormatException e) { + return fallback; + } + } +} diff --git a/backend/src/main/java/com/goldenchart/service/TrendSearchSnapshotService.java b/backend/src/main/java/com/goldenchart/service/TrendSearchSnapshotService.java new file mode 100644 index 0000000..a01b2a2 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/TrendSearchSnapshotService.java @@ -0,0 +1,129 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.dto.TrendSearchRequest; +import com.goldenchart.dto.TrendSearchResultDto; +import com.goldenchart.dto.TrendSearchResultsResponse; +import com.goldenchart.entity.GcAppSettings; +import com.goldenchart.entity.GcTrendSearchSnapshot; +import com.goldenchart.repository.GcAppSettingsRepository; +import com.goldenchart.repository.GcTrendSearchSnapshotRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TrendSearchSnapshotService { + + private static final TypeReference> RESULT_LIST_TYPE = new TypeReference<>() {}; + + private final GcTrendSearchSnapshotRepository snapshotRepo; + private final GcAppSettingsRepository appSettingsRepo; + private final TrendSearchService trendSearchService; + private final TrendSearchSettingsHelper settingsHelper; + private final ObjectMapper mapper; + + @Transactional(readOnly = true) + public TrendSearchResultsResponse getResults(Long userId, String deviceId) { + String scopeKey = TrendSearchScope.key(userId, deviceId); + return snapshotRepo.findById(scopeKey) + .map(this::toResponse) + .orElse(TrendSearchResultsResponse.empty()); + } + + @Transactional + public List scanAndPersist(Long userId, String deviceId, TrendSearchRequest request) { + GcAppSettings app = findAppSettings(userId, deviceId); + TrendSearchRequest req = app != null + ? settingsHelper.mergeRequestWithSettings(request, app) + : (request != null ? request : new TrendSearchRequest()); + List results = trendSearchService.scan(req); + persist(scopeKey(userId, deviceId), userId, deviceId, req, results); + return results; + } + + @Transactional + public void runScheduledScan(Long userId, String deviceId) { + String scopeKey = TrendSearchScope.key(userId, deviceId); + GcAppSettings app = findAppSettings(userId, deviceId); + if (app == null) return; + + TrendSearchRequest last = snapshotRepo.findById(scopeKey) + .map(s -> settingsHelper.parseRequestJson(s.getRequestJson())) + .orElse(null); + TrendSearchRequest req = settingsHelper.buildRequest(app, last); + List results = trendSearchService.scan(req); + persist(scopeKey, userId, deviceId, req, results); + log.debug("[TrendSearch] scheduled scan scope={} count={}", scopeKey, results.size()); + } + + private void persist(String scopeKey, Long userId, String deviceId, + TrendSearchRequest req, List results) { + try { + String resultsJson = mapper.writeValueAsString(results != null ? results : Collections.emptyList()); + String requestJson = mapper.writeValueAsString(req); + GcTrendSearchSnapshot snap = snapshotRepo.findById(scopeKey).orElseGet(() -> + GcTrendSearchSnapshot.builder().scopeKey(scopeKey).build()); + snap.setUserId(userId); + snap.setDeviceId(deviceId); + snap.setTimeframe(req.getTimeframe() != null ? req.getTimeframe() : "1d"); + snap.setScannedAt(LocalDateTime.now()); + snap.setRequestJson(requestJson); + snap.setResultsJson(resultsJson); + snapshotRepo.save(snap); + } catch (Exception e) { + log.warn("[TrendSearch] persist failed scope={}: {}", scopeKey, e.getMessage()); + } + } + + private TrendSearchResultsResponse toResponse(GcTrendSearchSnapshot snap) { + List list = Collections.emptyList(); + try { + if (snap.getResultsJson() != null && !snap.getResultsJson().isBlank()) { + list = mapper.readValue(snap.getResultsJson(), RESULT_LIST_TYPE); + } + } catch (Exception e) { + log.warn("[TrendSearch] deserialize results scope={}: {}", snap.getScopeKey(), e.getMessage()); + } + Instant scannedAt = snap.getScannedAt() != null + ? snap.getScannedAt().atZone(ZoneId.systemDefault()).toInstant() + : null; + return TrendSearchResultsResponse.builder() + .results(list) + .timeframe(snap.getTimeframe()) + .scannedAt(scannedAt) + .build(); + } + + private GcAppSettings findAppSettings(Long userId, String deviceId) { + if (userId != null) { + return appSettingsRepo.findByUserId(userId).orElse(null); + } + if (deviceId != null && !deviceId.isBlank()) { + return appSettingsRepo.findByDeviceId(deviceId).orElse(null); + } + return null; + } + + private static String scopeKey(Long userId, String deviceId) { + return TrendSearchScope.key(userId, deviceId); + } + + /** 스케줄러: 마지막 스캔 시각 (없으면 epoch) */ + @Transactional(readOnly = true) + public Optional lastScannedAt(Long userId, String deviceId) { + return snapshotRepo.findById(TrendSearchScope.key(userId, deviceId)) + .map(GcTrendSearchSnapshot::getScannedAt); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index cb9c8f2..8acce6a 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -90,6 +90,11 @@ goldenchart: stale-tick-seconds: 30 gap-backfill-cron: "0 */5 * * * *" gap-backfill-candle-count: 20 + trend-search: + scheduler: + enabled: true + tick-ms: 1000 + queue-capacity: 16 cors: allowed-origins: - http://localhost:5173 diff --git a/backend/src/main/resources/db/migration/V35__trend_search_snapshot.sql b/backend/src/main/resources/db/migration/V35__trend_search_snapshot.sql new file mode 100644 index 0000000..1393c48 --- /dev/null +++ b/backend/src/main/resources/db/migration/V35__trend_search_snapshot.sql @@ -0,0 +1,11 @@ +-- 추세검색 스캔 결과 스냅샷 (백엔드 스케줄러·수동 스캔 공통) +CREATE TABLE gc_trend_search_snapshot ( + scope_key VARCHAR(128) NOT NULL PRIMARY KEY, + user_id BIGINT NULL, + device_id VARCHAR(100) NULL, + timeframe VARCHAR(16) NOT NULL, + scanned_at TIMESTAMP(3) NOT NULL, + request_json JSON NULL, + results_json JSON NOT NULL, + INDEX idx_trend_snapshot_scanned (scanned_at) +); diff --git a/backend/src/test/java/com/goldenchart/service/TrendSearchBullishScoringTest.java b/backend/src/test/java/com/goldenchart/service/TrendSearchBullishScoringTest.java new file mode 100644 index 0000000..3df6df6 --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/TrendSearchBullishScoringTest.java @@ -0,0 +1,89 @@ +package com.goldenchart.service; + +import com.goldenchart.dto.TrendSearchRequest; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 추세검색 상승추세 배점·합격 점수 로직 검증. + */ +class TrendSearchBullishScoringTest { + + private static TrendSearchBullishScoring.BullishFlags flags( + boolean align, boolean slope, boolean adx, boolean macd, boolean price) { + return new TrendSearchBullishScoring.BullishFlags() { + @Override public boolean bullishEmaAlignment() { return align; } + @Override public boolean bullishEmaSlope() { return slope; } + @Override public boolean bullishAdxTrend() { return adx; } + @Override public boolean bullishMacdMomentum() { return macd; } + @Override public boolean bullishPricePosition() { return price; } + }; + } + + @Test + void allPass_defaultWeights_scores100() { + TrendSearchRequest r = new TrendSearchRequest(); + int score = TrendSearchBullishScoring.computeScore( + flags(true, true, true, true, true), r); + assertEquals(100, score); + } + + @Test + void partialPass_sumsOnlyMatchedWeights() { + TrendSearchRequest r = new TrendSearchRequest(); + r.setWeightMaAlignment(40); + r.setWeightMaSlope(10); + r.setWeightAdxTrend(20); + r.setWeightMacdMomentum(15); + r.setWeightPricePosition(15); + int score = TrendSearchBullishScoring.computeScore( + flags(true, false, true, false, true), r); + assertEquals(75, score); + } + + @Test + void customWeights_onlyAlignment_countsAlignmentOnly() { + TrendSearchRequest r = new TrendSearchRequest(); + r.setWeightMaAlignment(50); + r.setWeightMaSlope(0); + r.setWeightAdxTrend(0); + r.setWeightMacdMomentum(0); + r.setWeightPricePosition(0); + assertEquals(50, TrendSearchBullishScoring.computeScore( + flags(true, true, true, true, true), r)); + assertEquals(0, TrendSearchBullishScoring.computeScore( + flags(false, true, true, true, true), r)); + } + + @Test + void scoreCappedAt100_whenWeightsOverflow() { + TrendSearchRequest r = new TrendSearchRequest(); + r.setWeightMaAlignment(60); + r.setWeightMaSlope(60); + r.setWeightAdxTrend(60); + r.setWeightMacdMomentum(60); + r.setWeightPricePosition(60); + assertEquals(100, TrendSearchBullishScoring.computeScore( + flags(true, true, true, true, true), r)); + } + + @Test + void minTrendScore_filter() { + TrendSearchRequest r = new TrendSearchRequest(); + r.setMinTrendScore(70); + assertTrue(TrendSearchBullishScoring.passesMinTrendScore(70, r)); + assertTrue(TrendSearchBullishScoring.passesMinTrendScore(85, r)); + assertFalse(TrendSearchBullishScoring.passesMinTrendScore(69, r)); + r.setMinTrendScore(0); + assertTrue(TrendSearchBullishScoring.passesMinTrendScore(0, r)); + } + + @Test + void negativeWeights_treatedAsZero() { + TrendSearchRequest r = new TrendSearchRequest(); + r.setWeightMaAlignment(-10); + r.setWeightMaSlope(20); + assertEquals(20, TrendSearchBullishScoring.computeScore(flags(true, true, false, false, false), r)); + } +} diff --git a/backend/src/test/java/com/goldenchart/service/TrendSearchServiceMetricsTest.java b/backend/src/test/java/com/goldenchart/service/TrendSearchServiceMetricsTest.java new file mode 100644 index 0000000..8bffc1b --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/TrendSearchServiceMetricsTest.java @@ -0,0 +1,122 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.dto.CandleBarDto; +import com.goldenchart.dto.TrendSearchRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.reactive.function.client.WebClient; +import org.ta4j.core.BarSeries; +import org.ta4j.core.BaseBarSeriesBuilder; +import org.ta4j.core.bars.TimeBarBuilderFactory; +import org.ta4j.core.num.DoubleNumFactory; + +import java.lang.reflect.Method; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +/** + * 합성 상승 시계열에서 지표·배점 점수가 일관되는지 검증. + */ +class TrendSearchServiceMetricsTest { + + private TrendSearchService service; + private Method computeMetrics; + private Method computeBullishTrendScore; + private Class tickerSnapClass; + + @BeforeEach + void setUp() throws Exception { + service = new TrendSearchService( + mock(HistoricalDataService.class), + WebClient.builder(), + new ObjectMapper()); + for (Class c : TrendSearchService.class.getDeclaredClasses()) { + if ("TickerSnap".equals(c.getSimpleName())) tickerSnapClass = c; + } + for (Method m : TrendSearchService.class.getDeclaredMethods()) { + if ("computeMetrics".equals(m.getName())) computeMetrics = m; + if ("computeBullishTrendScore".equals(m.getName())) computeBullishTrendScore = m; + } + assertNotNull(computeMetrics); + assertNotNull(computeBullishTrendScore); + computeMetrics.setAccessible(true); + computeBullishTrendScore.setAccessible(true); + } + + @Test + void strongUptrend_series_earnsHighBullishScore() throws Exception { + BarSeries series = buildUptrendSeries(150); + List candles = toCandles(series); + Object tick = newTickerSnap(); + TrendSearchRequest req = new TrendSearchRequest(); + + Object metrics = computeMetrics.invoke(service, series, candles, tick, req); + int score = (int) computeBullishTrendScore.invoke(service, metrics, req); + + assertTrue(score >= 70, + "상승 추세 합성 데이터는 기본 배점(합 100) 기준 70점 이상이어야 함, actual=" + score); + } + + @Test + void highMinTrendScore_excludesLowScores_inScanLogic() { + TrendSearchRequest r = new TrendSearchRequest(); + r.setMinTrendScore(95); + assertFalse(TrendSearchBullishScoring.passesMinTrendScore(80, r)); + assertTrue(TrendSearchBullishScoring.passesMinTrendScore(95, r)); + } + + private Object newTickerSnap() throws Exception { + assertNotNull(tickerSnapClass); + var ctor = tickerSnapClass.getDeclaredConstructor( + String.class, String.class, double.class, double.class, double.class); + ctor.setAccessible(true); + return ctor.newInstance("KRW-TEST", "테스트", 100.0, 5.0, 1_000_000_000.0); + } + + private static BarSeries buildUptrendSeries(int bars) { + Duration period = Duration.ofDays(1); + BarSeries series = new BaseBarSeriesBuilder() + .withName("uptrend") + .withNumFactory(DoubleNumFactory.getInstance()) + .withBarBuilderFactory(new TimeBarBuilderFactory()) + .build(); + TimeBarBuilderFactory factory = new TimeBarBuilderFactory(); + Instant base = Instant.parse("2020-01-01T00:00:00Z"); + double price = 100; + for (int i = 0; i < bars; i++) { + double close = price * 1.008; + double high = Math.max(price, close) * 1.005; + double low = Math.min(price, close) * 0.995; + factory.createBarBuilder(series) + .timePeriod(period) + .endTime(base.plus(period.multipliedBy(i + 1L))) + .openPrice(price).highPrice(high).lowPrice(low).closePrice(close) + .volume(1_000_000 + i * 1000L) + .add(); + price = close; + } + return series; + } + + private static List toCandles(BarSeries series) { + List out = new ArrayList<>(); + for (int i = 0; i < series.getBarCount(); i++) { + var bar = series.getBar(i); + out.add(CandleBarDto.builder() + .time(bar.getBeginTime().getEpochSecond()) + .open(bar.getOpenPrice().doubleValue()) + .high(bar.getHighPrice().doubleValue()) + .low(bar.getLowPrice().doubleValue()) + .close(bar.getClosePrice().doubleValue()) + .volume(bar.getVolume().doubleValue()) + .build()); + } + return out; + } +} diff --git a/backend/src/test/java/com/goldenchart/service/TrendSearchSettingsHelperTest.java b/backend/src/test/java/com/goldenchart/service/TrendSearchSettingsHelperTest.java new file mode 100644 index 0000000..2ba2f06 --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/TrendSearchSettingsHelperTest.java @@ -0,0 +1,64 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.dto.TrendSearchRequest; +import com.goldenchart.entity.GcAppSettings; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 앱 설정 JSON → 스캔 요청 배점 병합 검증. + */ +class TrendSearchSettingsHelperTest { + + private TrendSearchSettingsHelper helper; + + @BeforeEach + void setUp() { + helper = new TrendSearchSettingsHelper(new ObjectMapper()); + } + + @Test + void mergeRequestWithSettings_appliesWeightsFromJson() throws Exception { + GcAppSettings app = GcAppSettings.builder() + .deviceId("test-device") + .trendSearchSettingsJson(""" + {"weightMaAlignment":50,"weightMaSlope":10,"weightAdxTrend":15, + "weightMacdMomentum":10,"weightPricePosition":15,"minTrendScore":60, + "limit":15,"scanLimit":40} + """) + .build(); + + TrendSearchRequest body = new TrendSearchRequest(); + body.setWeightMaAlignment(30); + body.setMinTrendScore(70); + + TrendSearchRequest merged = helper.mergeRequestWithSettings(body, app); + + assertEquals(50, merged.getWeightMaAlignment()); + assertEquals(10, merged.getWeightMaSlope()); + assertEquals(15, merged.getWeightAdxTrend()); + assertEquals(60, merged.getMinTrendScore()); + assertEquals(15, merged.getLimit()); + assertEquals(40, merged.getScanLimit()); + } + + @Test + void buildRequest_usesLastRequestTimeframe_whenPresent() throws Exception { + GcAppSettings app = GcAppSettings.builder() + .defaultTimeframe("1D") + .trendSearchSettingsJson("{\"weightMaAlignment\":25}") + .build(); + + TrendSearchRequest last = new TrendSearchRequest(); + last.setTimeframe("4h"); + last.setWeightMaAlignment(99); + + TrendSearchRequest built = helper.buildRequest(app, last); + + assertEquals("4h", built.getTimeframe()); + assertEquals(25, built.getWeightMaAlignment()); + } +} diff --git a/frontend/src/components/TrendSearchPage.tsx b/frontend/src/components/TrendSearchPage.tsx index 019b56d..c5c84a8 100644 --- a/frontend/src/components/TrendSearchPage.tsx +++ b/frontend/src/components/TrendSearchPage.tsx @@ -13,6 +13,7 @@ import type { TickerData } from '../hooks/useMarketTicker'; import { DEFAULT_TREND_SEARCH_REQUEST, scanTrendSearch, + loadTrendSearchResults, fetchTrendSearchDetail, type TrendSearchRequest, type TrendSearchResultDto, @@ -20,11 +21,12 @@ import { import { sortTrendSearchByStrength } from '../utils/trendSearchMetrics'; import { resolveVirtualTargetNames } from '../utils/virtualTargetNames'; import { useVirtualTargetRegistry } from '../hooks/useVirtualTargetRegistry'; -import { useAppSettings } from '../hooks/useAppSettings'; +import { getAppSettingsCache, useAppSettings } from '../hooks/useAppSettings'; import { - extractTrendSearchAppSettings, - mergeTrendSearchRequest, + mergeFiltersFromTrendSearchSettings, + mergeTrendSearchSettingsFromRequest, resolveTrendSearchAppSettings, + trendSearchSettingsSignature, type TrendSearchAppSettings, } from '../utils/trendSearchAppSettings'; import { autoAddTrendSearchTargets } from '../utils/trendSearchAutoAddTargets'; @@ -55,11 +57,10 @@ const TrendSearchPage: React.FC = ({ tickers, }) => { const { defaults, isLoaded: appSettingsLoaded, save: saveAppSettings } = useAppSettings(); - const [pageSettings, setPageSettings] = useState(() => - resolveTrendSearchAppSettings(), - ); + const tsSettings = defaults.trendSearchSettings; + const tsSettingsSig = trendSearchSettingsSignature(tsSettings); const [filters, setFilters] = useState(() => - mergeTrendSearchRequest( + mergeFiltersFromTrendSearchSettings( { ...DEFAULT_TREND_SEARCH_REQUEST }, resolveTrendSearchAppSettings(), ), @@ -67,75 +68,104 @@ const TrendSearchPage: React.FC = ({ const [results, setResults] = useState([]); const [selectedMarket, setSelectedMarket] = useState(null); const [searching, setSearching] = useState(false); - const [autoRefresh, setAutoRefresh] = useState(true); const [displayMode, setDisplayMode] = useState(() => loadDisplayMode()); const [flashMarkets, setFlashMarkets] = useState>(new Set()); const [lastUpdatedAt, setLastUpdatedAt] = useState(Date.now()); const filtersRef = useRef(filters); filtersRef.current = filters; - const pageSettingsRef = useRef(pageSettings); - pageSettingsRef.current = pageSettings; + const tsSettingsRef = useRef(tsSettings); + tsSettingsRef.current = tsSettings; + const lastSyncedSettingsSigRef = useRef(null); + const schedulerSeededRef = useRef(false); const { isInTargets, isPinned, add, remove, busyMarket } = useVirtualTargetRegistry(); useEffect(() => { if (!appSettingsLoaded) return; - const resolved = defaults.trendSearchSettings; - setPageSettings(resolved); - setFilters(prev => mergeTrendSearchRequest(prev, resolved)); - }, [appSettingsLoaded, defaults.trendSearchSettings]); + if (lastSyncedSettingsSigRef.current === tsSettingsSig) return; + lastSyncedSettingsSigRef.current = tsSettingsSig; + setFilters(prev => mergeFiltersFromTrendSearchSettings(prev, tsSettings)); + }, [appSettingsLoaded, tsSettingsSig, tsSettings]); - const persistPageSettings = useCallback((patch: Partial) => { - const next = resolveTrendSearchAppSettings({ ...pageSettingsRef.current, ...patch }); - setPageSettings(next); - pageSettingsRef.current = next; + useEffect(() => { + if (!appSettingsLoaded || schedulerSeededRef.current) return; + schedulerSeededRef.current = true; + saveAppSettings({ trendSearchSettings: tsSettingsRef.current }); + }, [appSettingsLoaded, saveAppSettings]); + + const persistTrendSettings = useCallback((patch: Partial) => { + const base = resolveTrendSearchAppSettings( + getAppSettingsCache().trendSearchSettings as Partial | undefined, + ); + const next = resolveTrendSearchAppSettings({ ...base, ...patch }); + lastSyncedSettingsSigRef.current = trendSearchSettingsSignature(next); saveAppSettings({ trendSearchSettings: next }); }, [saveAppSettings]); const handleFiltersChange = useCallback((next: TrendSearchRequest) => { setFilters(next); - const extracted = extractTrendSearchAppSettings(next); - persistPageSettings(extracted); - }, [persistPageSettings]); + const base = resolveTrendSearchAppSettings( + getAppSettingsCache().trendSearchSettings as Partial | undefined, + ); + const merged = mergeTrendSearchSettingsFromRequest(base, next); + lastSyncedSettingsSigRef.current = trendSearchSettingsSignature(merged); + saveAppSettings({ trendSearchSettings: merged }); + }, [saveAppSettings]); - const runSearch = useCallback(async (opts?: { silent?: boolean }) => { - if (!opts?.silent) setSearching(true); + const applyResults = useCallback(async (sorted: TrendSearchResultDto[], scannedAtMs?: number) => { + setResults(sorted); + setLastUpdatedAt(scannedAtMs ?? Date.now()); + setSelectedMarket(prev => { + if (prev && sorted.some(r => r.market === prev)) return prev; + return sorted[0]?.market ?? null; + }); + if (sorted.length > 0) { + setFlashMarkets(new Set(sorted.slice(0, 5).map(r => r.market))); + setTimeout(() => setFlashMarkets(new Set()), 600); + } + await autoAddTrendSearchTargets( + sorted, + tsSettingsRef.current, + filtersRef.current.timeframe, + ); + }, []); + + const loadFromDb = useCallback(async (opts?: { silent?: boolean }) => { + try { + const payload = await loadTrendSearchResults(); + const sorted = sortTrendSearchByStrength(payload.results ?? []); + const scannedAt = payload.scannedAt ? Date.parse(payload.scannedAt) : undefined; + await applyResults(sorted, Number.isFinite(scannedAt) ? scannedAt : undefined); + } catch (e) { + console.warn('[TrendSearch] load results', e); + if (!opts?.silent) window.alert('추세검색 결과를 불러오지 못했습니다. 백엔드 연결을 확인하세요.'); + } + }, [applyResults]); + + const runScan = useCallback(async () => { + setSearching(true); try { const list = await scanTrendSearch(filtersRef.current); const sorted = sortTrendSearchByStrength(list); - setResults(sorted); - setLastUpdatedAt(Date.now()); - setSelectedMarket(prev => { - if (prev && sorted.some(r => r.market === prev)) return prev; - return sorted[0]?.market ?? null; - }); - if (sorted.length > 0) { - setFlashMarkets(new Set(sorted.slice(0, 5).map(r => r.market))); - setTimeout(() => setFlashMarkets(new Set()), 600); - } - await autoAddTrendSearchTargets( - sorted, - pageSettingsRef.current, - filtersRef.current.timeframe, - ); + await applyResults(sorted); } catch (e) { - console.warn('[TrendSearch]', e); - if (!opts?.silent) window.alert('추세검색 스캔에 실패했습니다. 백엔드 연결을 확인하세요.'); + console.warn('[TrendSearch] scan', e); + window.alert('추세검색 스캔에 실패했습니다. 백엔드 연결을 확인하세요.'); } finally { - if (!opts?.silent) setSearching(false); + setSearching(false); } - }, []); + }, [applyResults]); useEffect(() => { if (!appSettingsLoaded) return; - void runSearch(); - }, [appSettingsLoaded, runSearch]); + void loadFromDb({ silent: true }); + }, [appSettingsLoaded, loadFromDb]); useEffect(() => { - if (!autoRefresh) return; - const ms = Math.max(1, pageSettings.autoRefreshSeconds) * 1000; - const id = window.setInterval(() => void runSearch({ silent: true }), ms); + if (!tsSettings.autoRefreshEnabled) return; + const ms = Math.max(1, tsSettings.autoRefreshSeconds) * 1000; + const id = window.setInterval(() => void loadFromDb({ silent: true }), ms); return () => clearInterval(id); - }, [autoRefresh, pageSettings.autoRefreshSeconds, runSearch]); + }, [tsSettings.autoRefreshEnabled, tsSettings.autoRefreshSeconds, loadFromDb]); useEffect(() => { try { localStorage.setItem(DISPLAY_MODE_KEY, displayMode); } catch { /* ignore */ } @@ -167,15 +197,19 @@ const TrendSearchPage: React.FC = ({ }, [remove]); const handleAutoAddToggle = useCallback((enabled: boolean) => { - persistPageSettings({ autoAddTargetsEnabled: enabled }); + persistTrendSettings({ autoAddTargetsEnabled: enabled }); if (enabled && results.length > 0) { - void autoAddTrendSearchTargets(results, { ...pageSettingsRef.current, autoAddTargetsEnabled: true }, filtersRef.current.timeframe); + void autoAddTrendSearchTargets(results, { ...tsSettingsRef.current, autoAddTargetsEnabled: true }, filtersRef.current.timeframe); } - }, [persistPageSettings, results]); + }, [persistTrendSettings, results]); + + const handleAutoRefreshToggle = useCallback((enabled: boolean) => { + persistTrendSettings({ autoRefreshEnabled: enabled }); + }, [persistTrendSettings]); const handleRefreshSecondsChange = useCallback((sec: number) => { - persistPageSettings({ autoRefreshSeconds: sec }); - }, [persistPageSettings]); + persistTrendSettings({ autoRefreshSeconds: sec }); + }, [persistTrendSettings]); return ( = ({ void runSearch()} + onRefresh={() => void loadFromDb()} resultCount={results.length} /> )} @@ -214,7 +248,7 @@ const TrendSearchPage: React.FC = ({ void runSearch()} + onSearch={() => void runScan()} searching={searching} /> )} diff --git a/frontend/src/components/VirtualTradingPage.tsx b/frontend/src/components/VirtualTradingPage.tsx index 2c8c57e..fe31ced 100644 --- a/frontend/src/components/VirtualTradingPage.tsx +++ b/frontend/src/components/VirtualTradingPage.tsx @@ -16,8 +16,10 @@ import { import type { Theme, TradeOrderFillRequest } from '../types'; import type { TickerData } from '../hooks/useMarketTicker'; import TradeOrderPanel from './TradeOrderPanel'; -import PaperCompactOrderbook from './paper/PaperCompactOrderbook'; +import { OrderbookPanel } from './OrderbookPanel'; import PaperSplitPanel from './paper/PaperSplitPanel'; +import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook'; +import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades'; import PaperTradeHistoryList from './paper/PaperTradeHistoryList'; import BuilderPageShell from './layout/BuilderPageShell'; import VirtualLeftTargetPanel from './virtual/VirtualLeftTargetPanel'; @@ -199,6 +201,23 @@ const VirtualTradingPage: React.FC = ({ [tickers, selectedMarket], ); + const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(selectedMarket); + const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(selectedMarket); + const selectedTicker = tickers?.get(selectedMarket); + const orderbookPrevClose = selectedTicker + ? (selectedTicker.tradePrice ?? 0) - (selectedTicker.changePrice ?? 0) + : 0; + const orderbookTickerInfo = selectedTicker ? { + tradePrice: selectedTicker.tradePrice, + changeRate: selectedTicker.changeRate, + changePrice: selectedTicker.changePrice, + accTradePrice24: selectedTicker.accTradePrice24, + accTradeVolume24: selectedTicker.accTradeVolume24, + highPrice: selectedTicker.highPrice, + lowPrice: selectedTicker.lowPrice, + openingPrice: selectedTicker.openingPrice, + } : undefined; + const handleSelectMarket = useCallback((market: string) => { setSelectedMarket(market); const price = tickers?.get(market)?.tradePrice; @@ -265,11 +284,13 @@ const VirtualTradingPage: React.FC = ({ setSession(s => ({ ...s, running: false })); }, [targets, session]); - const handleObPick = useCallback((price: number, rowType: 'ask' | 'bid') => { - const side = rowType === 'ask' ? 'buy' : 'sell'; - const req: TradeOrderFillRequest = { market: selectedMarket, price, side, seq: Date.now() }; - if (side === 'buy') setFillBuy(req); else setFillSell(req); + /** 실시간 차트 우측 호가와 동일: 매수 호가(bid) → 매수 탭, 매도 호가(ask) → 매도 탭 */ + const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => { setRightTab('trade'); + const side = rowType === 'bid' ? 'buy' : 'sell'; + const req: TradeOrderFillRequest = { market: selectedMarket, price, side, seq: Date.now() }; + if (side === 'buy') setFillBuy(req); + else setFillSell(req); }, [selectedMarket]); const handleTargetStrategyChange = useCallback(async (market: string, strategyId: number | null) => { @@ -497,35 +518,35 @@ const VirtualTradingPage: React.FC = ({ )} /> ) : rightTab === 'orderbook' ? ( - - )} - bottom={( - - )} - /> +
+
+
실시간 호가
+
+ +
+
+
) : ( ; + tickers?: Map; emptyText?: string; className?: string; onSelectMarket?: (market: string) => void; @@ -30,57 +33,102 @@ interface Props { const PaperTradeHistoryList: React.FC = ({ trades, strategyNames = {}, + tickers, emptyText = '거래 내역이 없습니다.', className = '', onSelectMarket, }) => ( -
- {trades.length === 0 ? ( -

{emptyText}

- ) : ( -
- - - - - - - - - - - - - +
+
+
+ 거래 내역 + {trades.length}건 +
+ + {trades.length === 0 ? ( +

{emptyText}

+ ) : ( +
+
{trades.map(t => { + const { koreanName: ko, englishName: en } = resolveVirtualTargetNames( + t.symbol, + tickers?.get(t.symbol)?.koreanName, + ); const strategyName = t.strategyId != null ? (strategyNames[t.strategyId] ?? `#${t.strategyId}`) - : null; + : '—'; + const isBuy = t.side === 'BUY'; + const clickable = Boolean(onSelectMarket); + const sideCls = isBuy ? 'vtd-trade-item--buy' : 'vtd-trade-item--sell'; + const colorCls = isBuy ? 'vtd-target-up' : 'vtd-target-dn'; + return ( -
onSelectMarket(t.symbol) : undefined} - title={strategyName ? `전략: ${strategyName}` : undefined} + className={[ + 'vtd-target-item', + 'vtd-trade-item', + sideCls, + clickable ? 'vtd-trade-item--click' : '', + ].filter(Boolean).join(' ')} + role={clickable ? 'button' : undefined} + tabIndex={clickable ? 0 : undefined} + onClick={clickable ? () => onSelectMarket!(t.symbol) : undefined} + onKeyDown={clickable ? e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelectMarket!(t.symbol); + } + } : undefined} > - - - - - - - - +
+
+ {ko} + {fmtTradeTime(t.createdAt)} · {en} +
+
+ + {isBuy ? '매수' : '매도'} + +
+
+ +
+
+ + {isBuy ? '▲' : '▼'} + + {coinCode(t.symbol)}/KRW +
+
+ {fmtKrw(t.price)} + {fmtKrw(t.netAmount)} +
+
+ +
+ 거래구분 + + {sourceLabel(t.source)} + +
+ +
+ 투자전략 + + {strategyName} + +
+ ); })} - -
시간종목유형구분체결가수량금액
{fmtTradeTime(t.createdAt)}{coinCode(t.symbol)} - {t.side === 'BUY' ? '매수' : '매도'} - - {sourceLabel(t.source)} - {fmtKrw(t.price)}{t.quantity}{fmtKrw(t.netAmount)}
-
- )} +
+ + )} + ); diff --git a/frontend/src/components/trendSearch/TrendSearchSettingsPanel.tsx b/frontend/src/components/trendSearch/TrendSearchSettingsPanel.tsx index 44f7a91..e7119bf 100644 --- a/frontend/src/components/trendSearch/TrendSearchSettingsPanel.tsx +++ b/frontend/src/components/trendSearch/TrendSearchSettingsPanel.tsx @@ -90,6 +90,19 @@ const TrendSearchSettingsPanel: React.FC = ({ settings, onChange }) => { + + +