추세검색 설정 수정
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<List<TrendSearchResultDto>> scan(@RequestBody TrendSearchRequest request) {
|
||||
public ResponseEntity<List<TrendSearchResultDto>> 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<TrendSearchResultsResponse> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TrendSearchResultDto> results;
|
||||
private String timeframe;
|
||||
private Instant scannedAt;
|
||||
|
||||
public static TrendSearchResultsResponse empty() {
|
||||
return TrendSearchResultsResponse.builder()
|
||||
.results(Collections.emptyList())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<GcAppSettings, Lo
|
||||
Optional<GcAppSettings> findByDeviceId(String deviceId);
|
||||
|
||||
Optional<GcAppSettings> findByUserId(Long userId);
|
||||
|
||||
List<GcAppSettings> findAllByTrendSearchSettingsJsonIsNotNull();
|
||||
}
|
||||
|
||||
@@ -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<GcTrendSearchSnapshot, String> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<String, AtomicBoolean> inFlight = new ConcurrentHashMap<>();
|
||||
|
||||
@Scheduled(fixedDelayString = "${goldenchart.trend-search.scheduler.tick-ms:1000}")
|
||||
public void tick() {
|
||||
if (!schedulerEnabled) return;
|
||||
|
||||
List<GcAppSettings> rows = appSettingsRepo.findAllByTrendSearchSettingsJsonIsNotNull();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
for (GcAppSettings app : rows) {
|
||||
Map<String, Object> 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<CandleBarDto> 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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, Object>> MAP_TYPE = new TypeReference<>() {};
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public Map<String, Object> 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<String, Object> settings) {
|
||||
Object v = settings.get("autoRefreshEnabled");
|
||||
if (v == null) return true;
|
||||
return Boolean.parseBoolean(v.toString());
|
||||
}
|
||||
|
||||
public int autoRefreshSeconds(Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<List<TrendSearchResultDto>> 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<TrendSearchResultDto> 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<TrendSearchResultDto> 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<TrendSearchResultDto> 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<TrendSearchResultDto> 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<TrendSearchResultDto> 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<LocalDateTime> lastScannedAt(Long userId, String deviceId) {
|
||||
return snapshotRepo.findById(TrendSearchScope.key(userId, deviceId))
|
||||
.map(GcTrendSearchSnapshot::getScannedAt);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<CandleBarDto> 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<CandleBarDto> toCandles(BarSeries series) {
|
||||
List<CandleBarDto> 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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user