추세검색 설정 수정

This commit is contained in:
Macbook
2026-05-27 02:10:00 +09:00
parent 2e08c6b16f
commit fc06a16184
27 changed files with 1262 additions and 147 deletions
@@ -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);
}
}