추세검색 설정 수정

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,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.TrendSearchRequest;
import com.goldenchart.dto.TrendSearchResultDto; import com.goldenchart.dto.TrendSearchResultDto;
import com.goldenchart.dto.TrendSearchResultsResponse;
import com.goldenchart.service.TrendSearchService; import com.goldenchart.service.TrendSearchService;
import com.goldenchart.service.TrendSearchSnapshotService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -13,8 +15,9 @@ import java.util.List;
/** /**
* 암호화폐 추세검색 API. * 암호화폐 추세검색 API.
* *
* POST /api/trend-search/scan — 조건 스캔 * POST /api/trend-search/scan — 조건 스캔 후 DB 스냅샷 갱신
* GET /api/trend-search/detail?market=KRW-BTC — 단일 종목 상세 * GET /api/trend-search/results — DB 스냅샷 조회 (자동 갱신 화면용)
* GET /api/trend-search/detail — 단일 종목 상세
*/ */
@RestController @RestController
@RequestMapping("/trend-search") @RequestMapping("/trend-search")
@@ -23,11 +26,24 @@ import java.util.List;
public class TrendSearchController { public class TrendSearchController {
private final TrendSearchService trendSearchService; private final TrendSearchService trendSearchService;
private final TrendSearchSnapshotService snapshotService;
@PostMapping("/scan") @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()); 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") @GetMapping("/detail")
@@ -38,4 +54,13 @@ public class TrendSearchController {
if (timeframe != null) req.setTimeframe(timeframe); if (timeframe != null) req.setTimeframe(timeframe);
return ResponseEntity.ok(trendSearchService.detail(market, req)); 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.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional; import java.util.Optional;
@Repository @Repository
@@ -12,4 +13,6 @@ public interface GcAppSettingsRepository extends JpaRepository<GcAppSettings, Lo
Optional<GcAppSettings> findByDeviceId(String deviceId); Optional<GcAppSettings> findByDeviceId(String deviceId);
Optional<GcAppSettings> findByUserId(Long userId); 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) { if (useBullishScore) {
m.bullishTrendScore = computeBullishTrendScore(m, r); m.bullishTrendScore = computeBullishTrendScore(m, r);
m.matchScore = m.bullishTrendScore; m.matchScore = m.bullishTrendScore;
if (minTrendScore > 0 && m.bullishTrendScore < minTrendScore) continue; if (!TrendSearchBullishScoring.passesMinTrendScore(m.bullishTrendScore, r)) continue;
} else { } else {
m.trendStrength = computeTrendStrength(m); m.trendStrength = computeTrendStrength(m);
m.matchScore = enabledFilters == 0 m.matchScore = enabledFilters == 0
@@ -365,13 +365,7 @@ public class TrendSearchService {
/** 상승추세 검색그룹 — 가중 점수 합산 (0~100) */ /** 상승추세 검색그룹 — 가중 점수 합산 (0~100) */
private int computeBullishTrendScore(MetricValues m, TrendSearchRequest r) { private int computeBullishTrendScore(MetricValues m, TrendSearchRequest r) {
int score = 0; return TrendSearchBullishScoring.computeScore(m, r);
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);
} }
/** 정배열·상승세·거래량 복합 추세 강도 (0~100) */ /** 정배열·상승세·거래량 복합 추세 강도 (0~100) */
@@ -830,7 +824,7 @@ public class TrendSearchService {
private record ScoredMarket(String market, TickerSnap tick, List<CandleBarDto> candles, MetricValues metrics) {} 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 boolean priceAboveMa, maAlignment, maConvergence;
final double maSpreadPct; final double maSpreadPct;
final boolean ma20SlopeUp, newHighBreakout, ichimokuAboveCloud; final boolean ma20SlopeUp, newHighBreakout, ichimokuAboveCloud;
@@ -895,5 +889,11 @@ public class TrendSearchService {
this.bullishMacdMomentum = bullishMacdMomentum; this.bullishMacdMomentum = bullishMacdMomentum;
this.bullishPricePosition = bullishPricePosition; 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 stale-tick-seconds: 30
gap-backfill-cron: "0 */5 * * * *" gap-backfill-cron: "0 */5 * * * *"
gap-backfill-candle-count: 20 gap-backfill-candle-count: 20
trend-search:
scheduler:
enabled: true
tick-ms: 1000
queue-capacity: 16
cors: cors:
allowed-origins: allowed-origins:
- http://localhost:5173 - 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());
}
}
+85 -51
View File
@@ -13,6 +13,7 @@ import type { TickerData } from '../hooks/useMarketTicker';
import { import {
DEFAULT_TREND_SEARCH_REQUEST, DEFAULT_TREND_SEARCH_REQUEST,
scanTrendSearch, scanTrendSearch,
loadTrendSearchResults,
fetchTrendSearchDetail, fetchTrendSearchDetail,
type TrendSearchRequest, type TrendSearchRequest,
type TrendSearchResultDto, type TrendSearchResultDto,
@@ -20,11 +21,12 @@ import {
import { sortTrendSearchByStrength } from '../utils/trendSearchMetrics'; import { sortTrendSearchByStrength } from '../utils/trendSearchMetrics';
import { resolveVirtualTargetNames } from '../utils/virtualTargetNames'; import { resolveVirtualTargetNames } from '../utils/virtualTargetNames';
import { useVirtualTargetRegistry } from '../hooks/useVirtualTargetRegistry'; import { useVirtualTargetRegistry } from '../hooks/useVirtualTargetRegistry';
import { useAppSettings } from '../hooks/useAppSettings'; import { getAppSettingsCache, useAppSettings } from '../hooks/useAppSettings';
import { import {
extractTrendSearchAppSettings, mergeFiltersFromTrendSearchSettings,
mergeTrendSearchRequest, mergeTrendSearchSettingsFromRequest,
resolveTrendSearchAppSettings, resolveTrendSearchAppSettings,
trendSearchSettingsSignature,
type TrendSearchAppSettings, type TrendSearchAppSettings,
} from '../utils/trendSearchAppSettings'; } from '../utils/trendSearchAppSettings';
import { autoAddTrendSearchTargets } from '../utils/trendSearchAutoAddTargets'; import { autoAddTrendSearchTargets } from '../utils/trendSearchAutoAddTargets';
@@ -55,11 +57,10 @@ const TrendSearchPage: React.FC<Props> = ({
tickers, tickers,
}) => { }) => {
const { defaults, isLoaded: appSettingsLoaded, save: saveAppSettings } = useAppSettings(); const { defaults, isLoaded: appSettingsLoaded, save: saveAppSettings } = useAppSettings();
const [pageSettings, setPageSettings] = useState<TrendSearchAppSettings>(() => const tsSettings = defaults.trendSearchSettings;
resolveTrendSearchAppSettings(), const tsSettingsSig = trendSearchSettingsSignature(tsSettings);
);
const [filters, setFilters] = useState<TrendSearchRequest>(() => const [filters, setFilters] = useState<TrendSearchRequest>(() =>
mergeTrendSearchRequest( mergeFiltersFromTrendSearchSettings(
{ ...DEFAULT_TREND_SEARCH_REQUEST }, { ...DEFAULT_TREND_SEARCH_REQUEST },
resolveTrendSearchAppSettings(), resolveTrendSearchAppSettings(),
), ),
@@ -67,43 +68,52 @@ const TrendSearchPage: React.FC<Props> = ({
const [results, setResults] = useState<TrendSearchResultDto[]>([]); const [results, setResults] = useState<TrendSearchResultDto[]>([]);
const [selectedMarket, setSelectedMarket] = useState<string | null>(null); const [selectedMarket, setSelectedMarket] = useState<string | null>(null);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(true);
const [displayMode, setDisplayMode] = useState<TrendSearchDisplayMode>(() => loadDisplayMode()); const [displayMode, setDisplayMode] = useState<TrendSearchDisplayMode>(() => loadDisplayMode());
const [flashMarkets, setFlashMarkets] = useState<Set<string>>(new Set()); const [flashMarkets, setFlashMarkets] = useState<Set<string>>(new Set());
const [lastUpdatedAt, setLastUpdatedAt] = useState<number>(Date.now()); const [lastUpdatedAt, setLastUpdatedAt] = useState<number>(Date.now());
const filtersRef = useRef(filters); const filtersRef = useRef(filters);
filtersRef.current = filters; filtersRef.current = filters;
const pageSettingsRef = useRef(pageSettings); const tsSettingsRef = useRef(tsSettings);
pageSettingsRef.current = pageSettings; tsSettingsRef.current = tsSettings;
const lastSyncedSettingsSigRef = useRef<string | null>(null);
const schedulerSeededRef = useRef(false);
const { isInTargets, isPinned, add, remove, busyMarket } = useVirtualTargetRegistry(); const { isInTargets, isPinned, add, remove, busyMarket } = useVirtualTargetRegistry();
useEffect(() => { useEffect(() => {
if (!appSettingsLoaded) return; if (!appSettingsLoaded) return;
const resolved = defaults.trendSearchSettings; if (lastSyncedSettingsSigRef.current === tsSettingsSig) return;
setPageSettings(resolved); lastSyncedSettingsSigRef.current = tsSettingsSig;
setFilters(prev => mergeTrendSearchRequest(prev, resolved)); setFilters(prev => mergeFiltersFromTrendSearchSettings(prev, tsSettings));
}, [appSettingsLoaded, defaults.trendSearchSettings]); }, [appSettingsLoaded, tsSettingsSig, tsSettings]);
const persistPageSettings = useCallback((patch: Partial<TrendSearchAppSettings>) => { useEffect(() => {
const next = resolveTrendSearchAppSettings({ ...pageSettingsRef.current, ...patch }); if (!appSettingsLoaded || schedulerSeededRef.current) return;
setPageSettings(next); schedulerSeededRef.current = true;
pageSettingsRef.current = next; saveAppSettings({ trendSearchSettings: tsSettingsRef.current });
}, [appSettingsLoaded, saveAppSettings]);
const persistTrendSettings = useCallback((patch: Partial<TrendSearchAppSettings>) => {
const base = resolveTrendSearchAppSettings(
getAppSettingsCache().trendSearchSettings as Partial<TrendSearchAppSettings> | undefined,
);
const next = resolveTrendSearchAppSettings({ ...base, ...patch });
lastSyncedSettingsSigRef.current = trendSearchSettingsSignature(next);
saveAppSettings({ trendSearchSettings: next }); saveAppSettings({ trendSearchSettings: next });
}, [saveAppSettings]); }, [saveAppSettings]);
const handleFiltersChange = useCallback((next: TrendSearchRequest) => { const handleFiltersChange = useCallback((next: TrendSearchRequest) => {
setFilters(next); setFilters(next);
const extracted = extractTrendSearchAppSettings(next); const base = resolveTrendSearchAppSettings(
persistPageSettings(extracted); getAppSettingsCache().trendSearchSettings as Partial<TrendSearchAppSettings> | undefined,
}, [persistPageSettings]); );
const merged = mergeTrendSearchSettingsFromRequest(base, next);
lastSyncedSettingsSigRef.current = trendSearchSettingsSignature(merged);
saveAppSettings({ trendSearchSettings: merged });
}, [saveAppSettings]);
const runSearch = useCallback(async (opts?: { silent?: boolean }) => { const applyResults = useCallback(async (sorted: TrendSearchResultDto[], scannedAtMs?: number) => {
if (!opts?.silent) setSearching(true);
try {
const list = await scanTrendSearch(filtersRef.current);
const sorted = sortTrendSearchByStrength(list);
setResults(sorted); setResults(sorted);
setLastUpdatedAt(Date.now()); setLastUpdatedAt(scannedAtMs ?? Date.now());
setSelectedMarket(prev => { setSelectedMarket(prev => {
if (prev && sorted.some(r => r.market === prev)) return prev; if (prev && sorted.some(r => r.market === prev)) return prev;
return sorted[0]?.market ?? null; return sorted[0]?.market ?? null;
@@ -114,28 +124,48 @@ const TrendSearchPage: React.FC<Props> = ({
} }
await autoAddTrendSearchTargets( await autoAddTrendSearchTargets(
sorted, sorted,
pageSettingsRef.current, tsSettingsRef.current,
filtersRef.current.timeframe, filtersRef.current.timeframe,
); );
} catch (e) {
console.warn('[TrendSearch]', e);
if (!opts?.silent) window.alert('추세검색 스캔에 실패했습니다. 백엔드 연결을 확인하세요.');
} finally {
if (!opts?.silent) setSearching(false);
}
}, []); }, []);
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);
await applyResults(sorted);
} catch (e) {
console.warn('[TrendSearch] scan', e);
window.alert('추세검색 스캔에 실패했습니다. 백엔드 연결을 확인하세요.');
} finally {
setSearching(false);
}
}, [applyResults]);
useEffect(() => { useEffect(() => {
if (!appSettingsLoaded) return; if (!appSettingsLoaded) return;
void runSearch(); void loadFromDb({ silent: true });
}, [appSettingsLoaded, runSearch]); }, [appSettingsLoaded, loadFromDb]);
useEffect(() => { useEffect(() => {
if (!autoRefresh) return; if (!tsSettings.autoRefreshEnabled) return;
const ms = Math.max(1, pageSettings.autoRefreshSeconds) * 1000; const ms = Math.max(1, tsSettings.autoRefreshSeconds) * 1000;
const id = window.setInterval(() => void runSearch({ silent: true }), ms); const id = window.setInterval(() => void loadFromDb({ silent: true }), ms);
return () => clearInterval(id); return () => clearInterval(id);
}, [autoRefresh, pageSettings.autoRefreshSeconds, runSearch]); }, [tsSettings.autoRefreshEnabled, tsSettings.autoRefreshSeconds, loadFromDb]);
useEffect(() => { useEffect(() => {
try { localStorage.setItem(DISPLAY_MODE_KEY, displayMode); } catch { /* ignore */ } try { localStorage.setItem(DISPLAY_MODE_KEY, displayMode); } catch { /* ignore */ }
@@ -167,15 +197,19 @@ const TrendSearchPage: React.FC<Props> = ({
}, [remove]); }, [remove]);
const handleAutoAddToggle = useCallback((enabled: boolean) => { const handleAutoAddToggle = useCallback((enabled: boolean) => {
persistPageSettings({ autoAddTargetsEnabled: enabled }); persistTrendSettings({ autoAddTargetsEnabled: enabled });
if (enabled && results.length > 0) { 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) => { const handleRefreshSecondsChange = useCallback((sec: number) => {
persistPageSettings({ autoRefreshSeconds: sec }); persistTrendSettings({ autoRefreshSeconds: sec });
}, [persistPageSettings]); }, [persistTrendSettings]);
return ( return (
<BuilderPageShell <BuilderPageShell
@@ -195,14 +229,14 @@ const TrendSearchPage: React.FC<Props> = ({
<TrendSearchViewHeaderControls <TrendSearchViewHeaderControls
displayMode={displayMode} displayMode={displayMode}
onDisplayModeChange={setDisplayMode} onDisplayModeChange={setDisplayMode}
autoRefresh={autoRefresh} autoRefresh={tsSettings.autoRefreshEnabled}
onAutoRefreshChange={setAutoRefresh} onAutoRefreshChange={handleAutoRefreshToggle}
refreshSeconds={pageSettings.autoRefreshSeconds} refreshSeconds={tsSettings.autoRefreshSeconds}
onRefreshSecondsChange={handleRefreshSecondsChange} onRefreshSecondsChange={handleRefreshSecondsChange}
autoAddTargets={pageSettings.autoAddTargetsEnabled} autoAddTargets={tsSettings.autoAddTargetsEnabled}
onAutoAddTargetsChange={handleAutoAddToggle} onAutoAddTargetsChange={handleAutoAddToggle}
searching={searching} searching={searching}
onRefresh={() => void runSearch()} onRefresh={() => void loadFromDb()}
resultCount={results.length} resultCount={results.length}
/> />
)} )}
@@ -214,7 +248,7 @@ const TrendSearchPage: React.FC<Props> = ({
<TrendSearchFilterPanel <TrendSearchFilterPanel
filters={filters} filters={filters}
onChange={handleFiltersChange} onChange={handleFiltersChange}
onSearch={() => void runSearch()} onSearch={() => void runScan()}
searching={searching} searching={searching}
/> />
)} )}
+49 -28
View File
@@ -16,8 +16,10 @@ import {
import type { Theme, TradeOrderFillRequest } from '../types'; import type { Theme, TradeOrderFillRequest } from '../types';
import type { TickerData } from '../hooks/useMarketTicker'; import type { TickerData } from '../hooks/useMarketTicker';
import TradeOrderPanel from './TradeOrderPanel'; import TradeOrderPanel from './TradeOrderPanel';
import PaperCompactOrderbook from './paper/PaperCompactOrderbook'; import { OrderbookPanel } from './OrderbookPanel';
import PaperSplitPanel from './paper/PaperSplitPanel'; import PaperSplitPanel from './paper/PaperSplitPanel';
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
import PaperTradeHistoryList from './paper/PaperTradeHistoryList'; import PaperTradeHistoryList from './paper/PaperTradeHistoryList';
import BuilderPageShell from './layout/BuilderPageShell'; import BuilderPageShell from './layout/BuilderPageShell';
import VirtualLeftTargetPanel from './virtual/VirtualLeftTargetPanel'; import VirtualLeftTargetPanel from './virtual/VirtualLeftTargetPanel';
@@ -199,6 +201,23 @@ const VirtualTradingPage: React.FC<Props> = ({
[tickers, selectedMarket], [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) => { const handleSelectMarket = useCallback((market: string) => {
setSelectedMarket(market); setSelectedMarket(market);
const price = tickers?.get(market)?.tradePrice; const price = tickers?.get(market)?.tradePrice;
@@ -265,11 +284,13 @@ const VirtualTradingPage: React.FC<Props> = ({
setSession(s => ({ ...s, running: false })); setSession(s => ({ ...s, running: false }));
}, [targets, session]); }, [targets, session]);
const handleObPick = useCallback((price: number, rowType: 'ask' | 'bid') => { /** 실시간 차트 우측 호가와 동일: 매수 호가(bid) → 매수 탭, 매도 호가(ask) → 매도 탭 */
const side = rowType === 'ask' ? 'buy' : 'sell'; const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => {
const req: TradeOrderFillRequest = { market: selectedMarket, price, side, seq: Date.now() };
if (side === 'buy') setFillBuy(req); else setFillSell(req);
setRightTab('trade'); 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]); }, [selectedMarket]);
const handleTargetStrategyChange = useCallback(async (market: string, strategyId: number | null) => { const handleTargetStrategyChange = useCallback(async (market: string, strategyId: number | null) => {
@@ -497,35 +518,35 @@ const VirtualTradingPage: React.FC<Props> = ({
)} )}
/> />
) : rightTab === 'orderbook' ? ( ) : rightTab === 'orderbook' ? (
<PaperSplitPanel <div className="rsp-ob-stack ptd-ob-stack--fill">
className="ptd-split-panel--right" <div className="rsp-trade-card rsp-ob-card">
topTitle="매도 호가" <div className="rsp-trade-card-title rsp-ob-card-title"> </div>
bottomTitle="매수 호가" <div className="rsp-trade-card-body rsp-ob-card-body">
top={( <OrderbookPanel
<PaperCompactOrderbook
market={selectedMarket} market={selectedMarket}
onPick={handleObPick} asks={orderbook.asks}
section="asks" bids={orderbook.bids}
fillHeight totalAskSize={orderbook.totalAskSize}
hideHeader totalBidSize={orderbook.totalBidSize}
depth={10} wsStatus={obWsStatus}
/> bestAsk={spread.bestAsk}
)} bestBid={spread.bestBid}
bottom={( spread={spread.spread}
<PaperCompactOrderbook spreadPct={spread.pct}
market={selectedMarket} prevClose={orderbookPrevClose}
onPick={handleObPick} tickerInfo={orderbookTickerInfo}
section="bids" recentTrades={recentTrades}
fillHeight tradeStrength={tradeStrength}
hideHeader onRowClick={handleOrderbookRowClick}
depth={10}
/>
)}
/> />
</div>
</div>
</div>
) : ( ) : (
<PaperTradeHistoryList <PaperTradeHistoryList
trades={trades} trades={trades}
strategyNames={strategyNames} strategyNames={strategyNames}
tickers={tickers}
onSelectMarket={handleSelectMarket} onSelectMarket={handleSelectMarket}
emptyText="수동·자동 매매 체결 내역이 없습니다." emptyText="수동·자동 매매 체결 내역이 없습니다."
className="ptd-trade-history--fill" className="ptd-trade-history--fill"
@@ -1,5 +1,7 @@
import React from 'react'; import React from 'react';
import type { PaperTradeDto } from '../../utils/backendApi'; import type { PaperTradeDto } from '../../utils/backendApi';
import type { TickerData } from '../../hooks/useMarketTicker';
import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames';
import { fmtKrw } from '../TradeOrderPanel'; import { fmtKrw } from '../TradeOrderPanel';
function coinCode(symbol: string): string { function coinCode(symbol: string): string {
@@ -22,6 +24,7 @@ function sourceLabel(source: string | null | undefined): string {
interface Props { interface Props {
trades: PaperTradeDto[]; trades: PaperTradeDto[];
strategyNames?: Record<number, string>; strategyNames?: Record<number, string>;
tickers?: Map<string, TickerData>;
emptyText?: string; emptyText?: string;
className?: string; className?: string;
onSelectMarket?: (market: string) => void; onSelectMarket?: (market: string) => void;
@@ -30,57 +33,102 @@ interface Props {
const PaperTradeHistoryList: React.FC<Props> = ({ const PaperTradeHistoryList: React.FC<Props> = ({
trades, trades,
strategyNames = {}, strategyNames = {},
tickers,
emptyText = '거래 내역이 없습니다.', emptyText = '거래 내역이 없습니다.',
className = '', className = '',
onSelectMarket, onSelectMarket,
}) => ( }) => (
<div className={`ptd-trade-history${className ? ` ${className}` : ''}`}> <div className={`ptd-trade-history vtd-trade-history${className ? ` ${className}` : ''}`}>
<section className="vtd-target-section vtd-trade-section">
<div className="vtd-target-list-head">
<span> </span>
<span className="vtd-target-count">{trades.length}</span>
</div>
{trades.length === 0 ? ( {trades.length === 0 ? (
<p className="ptd-muted ptd-trade-history-empty">{emptyText}</p> <p className="vtd-muted ptd-trade-history-empty">{emptyText}</p>
) : ( ) : (
<div className="ptd-trade-history-scroll"> <div className="ptd-trade-history-scroll">
<table className="ptd-table ptd-table--compact ptd-table--trades"> <div className="vtd-target-list vtd-trade-list">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{trades.map(t => { {trades.map(t => {
const { koreanName: ko, englishName: en } = resolveVirtualTargetNames(
t.symbol,
tickers?.get(t.symbol)?.koreanName,
);
const strategyName = t.strategyId != null const strategyName = t.strategyId != null
? (strategyNames[t.strategyId] ?? `#${t.strategyId}`) ? (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 ( return (
<tr <div
key={t.id} key={t.id}
className={onSelectMarket ? 'ptd-row--click' : undefined} className={[
onClick={onSelectMarket ? () => onSelectMarket(t.symbol) : undefined} 'vtd-target-item',
title={strategyName ? `전략: ${strategyName}` : undefined} '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}
>
<div className="vtd-target-item-main">
<div className="vtd-target-names">
<span className="vtd-target-ko">{ko}</span>
<span className="vtd-target-en">{fmtTradeTime(t.createdAt)} · {en}</span>
</div>
<div className="vtd-target-item-actions">
<span className={`vtd-trade-side-badge vtd-trade-side-badge--${isBuy ? 'buy' : 'sell'}`}>
{isBuy ? '매수' : '매도'}
</span>
</div>
</div>
<div className="vtd-target-quote vtd-trade-quote" aria-label="체결 정보">
<div className="vtd-target-quote-left">
<span className={`vtd-target-arrow vtd-target-arrow--${isBuy ? 'rise' : 'fall'}`}>
{isBuy ? '▲' : '▼'}
</span>
<span className="vtd-target-quote-pair">{coinCode(t.symbol)}/KRW</span>
</div>
<div className="vtd-target-quote-right">
<span className={`vtd-target-price ${colorCls}`}>{fmtKrw(t.price)}</span>
<span className={`vtd-target-change ${colorCls}`}>{fmtKrw(t.netAmount)}</span>
</div>
</div>
<div className="vtd-target-strategy-field vtd-trade-meta-field">
<span className="vtd-target-strategy-label"></span>
<span
className={`vtd-trade-meta-value${t.source === 'STRATEGY' ? ' vtd-trade-meta-value--auto' : ''}`}
> >
<td className="ptd-time">{fmtTradeTime(t.createdAt)}</td>
<td>{coinCode(t.symbol)}</td>
<td className={t.side === 'BUY' ? 'up' : 'down'}>
{t.side === 'BUY' ? '매수' : '매도'}
</td>
<td className={t.source === 'STRATEGY' ? 'ptd-source--auto' : 'ptd-source--manual'}>
{sourceLabel(t.source)} {sourceLabel(t.source)}
</td> </span>
<td>{fmtKrw(t.price)}</td> </div>
<td>{t.quantity}</td>
<td>{fmtKrw(t.netAmount)}</td> <div className="vtd-target-strategy-field vtd-trade-meta-field">
</tr> <span className="vtd-target-strategy-label"></span>
<span className="vtd-trade-meta-value vtd-trade-meta-value--strategy" title={strategyName}>
{strategyName}
</span>
</div>
</div>
); );
})} })}
</tbody> </div>
</table>
</div> </div>
)} )}
</section>
</div> </div>
); );
@@ -90,6 +90,19 @@ const TrendSearchSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
</SettingSection> </SettingSection>
<SettingSection title="자동 갱신"> <SettingSection title="자동 갱신">
<SettingRow
label="자동 갱신 사용"
desc="백엔드에서 설정 간격으로 스캔하고, 화면은 DB 결과를 주기적으로 불러옵니다."
>
<label className="stg-toggle">
<input
type="checkbox"
checked={settings.autoRefreshEnabled}
onChange={e => patch({ autoRefreshEnabled: e.target.checked })}
/>
<span>{settings.autoRefreshEnabled ? '켜짐' : '꺼짐'}</span>
</label>
</SettingRow>
<SettingRow label="기본 갱신 간격" desc="추세검색 화면 자동 갱신 드롭다운의 기본값입니다."> <SettingRow label="기본 갱신 간격" desc="추세검색 화면 자동 갱신 드롭다운의 기본값입니다.">
<select <select
className="stg-select" className="stg-select"
+29 -2
View File
@@ -49,6 +49,22 @@ import {
// 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지 // 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지
let _cache: AppSettingsDto | null = null; let _cache: AppSettingsDto | null = null;
let _loadPromise: Promise<AppSettingsDto> | null = null; let _loadPromise: Promise<AppSettingsDto> | null = null;
type AppSettingsListener = () => void;
const _listeners = new Set<AppSettingsListener>();
function notifyAppSettingsListeners() {
_listeners.forEach(fn => fn());
}
/** 훅 외부에서 최신 캐시 읽기 (추세검색 설정 병합 등) */
export function getAppSettingsCache(): AppSettingsDto {
return _cache ?? {};
}
export function subscribeAppSettings(listener: AppSettingsListener): () => void {
_listeners.add(listener);
return () => _listeners.delete(listener);
}
function ensureLoaded(): Promise<AppSettingsDto> { function ensureLoaded(): Promise<AppSettingsDto> {
if (_cache !== null) return Promise.resolve(_cache); if (_cache !== null) return Promise.resolve(_cache);
@@ -56,6 +72,7 @@ function ensureLoaded(): Promise<AppSettingsDto> {
_loadPromise = loadAppSettings().then(data => { _loadPromise = loadAppSettings().then(data => {
_cache = data ?? {}; _cache = data ?? {};
_loadPromise = null; _loadPromise = null;
notifyAppSettingsListeners();
return _cache; return _cache;
}); });
return _loadPromise; return _loadPromise;
@@ -141,7 +158,13 @@ export function useAppSettings(sessionKey = 0) {
console.error('[useAppSettings] load failed', err); console.error('[useAppSettings] load failed', err);
if (mountedRef.current) setIsLoaded(true); if (mountedRef.current) setIsLoaded(true);
}); });
return () => { mountedRef.current = false; }; const unsub = subscribeAppSettings(() => {
if (mountedRef.current) setSettings({ ...(getAppSettingsCache()) });
});
return () => {
mountedRef.current = false;
unsub();
};
}, [sessionKey]); }, [sessionKey]);
/** /**
@@ -151,15 +174,19 @@ export function useAppSettings(sessionKey = 0) {
const save = useCallback((patch: AppSettingsDto) => { const save = useCallback((patch: AppSettingsDto) => {
const merged = { ...(_cache ?? {}), ...patch }; const merged = { ...(_cache ?? {}), ...patch };
_cache = merged; _cache = merged;
notifyAppSettingsListeners();
setSettings(merged); setSettings(merged);
if (patch.displayTimezone != null) { if (patch.displayTimezone != null) {
setDisplayTimezone(normalizeTimezone(patch.displayTimezone)); setDisplayTimezone(normalizeTimezone(patch.displayTimezone));
} }
saveAppSettings(patch).then(updated => { saveAppSettings(patch).then(updated => {
if (updated && mountedRef.current) { if (updated) {
_cache = { ...(_cache ?? {}), ...updated }; _cache = { ...(_cache ?? {}), ...updated };
notifyAppSettingsListeners();
if (mountedRef.current) {
setSettings(prev => ({ ...prev, ...updated })); setSettings(prev => ({ ...prev, ...updated }));
} }
}
}).catch(err => }).catch(err =>
console.error('[useAppSettings] save failed', err) console.error('[useAppSettings] save failed', err)
); );
+24 -2
View File
@@ -837,13 +837,14 @@
.ptd-trade-history--fill { .ptd-trade-history--fill {
flex: 1; flex: 1;
padding: 8px 10px; padding: 10px 12px;
box-sizing: border-box; box-sizing: border-box;
} }
.ptd-trade-history-empty { .ptd-trade-history-empty {
margin: 12px 0; margin: 16px 0;
text-align: center; text-align: center;
font-size: 13px;
} }
.ptd-trade-history-scroll { .ptd-trade-history-scroll {
@@ -852,6 +853,15 @@
overflow: auto; overflow: auto;
} }
.bps-page--vtd .ptd-trade-history--fill .vtd-trade-section {
height: 100%;
}
.bps-page--vtd .ptd-trade-history--fill .ptd-trade-history-scroll {
flex: 1;
min-height: 0;
}
.ptd-table--trades th, .ptd-table--trades th,
.ptd-table--trades td { .ptd-table--trades td {
white-space: nowrap; white-space: nowrap;
@@ -877,6 +887,18 @@
.bps-page--vtd .ptd-right-body > .ptd-trade-history--fill { .bps-page--vtd .ptd-right-body > .ptd-trade-history--fill {
height: 100%; height: 100%;
} }
/* 가상매매 — 실시간 차트와 동일한 호가 탭 레이아웃 */
.bps-page--vtd .ptd-right-body > .ptd-ob-stack--fill {
flex: 1;
min-height: 0;
height: 100%;
box-sizing: border-box;
}
.bps-page--vtd .ptd-right-body > .ptd-ob-stack--fill.rsp-ob-stack {
padding: 10px 12px 12px;
}
.ptd-status { .ptd-status {
display: inline-block; display: inline-block;
padding: 2px 5px; padding: 2px 5px;
@@ -538,6 +538,113 @@
font-size: 12px; font-size: 12px;
} }
/* ── 우측 거래 탭 — 투자대상과 동일 카드 박스 ── */
.vtd-trade-history {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
box-sizing: border-box;
}
.vtd-trade-section {
flex: 1;
min-height: 0;
padding: 10px 12px 12px;
}
.vtd-trade-list {
gap: 10px;
}
.vtd-trade-item {
cursor: default;
}
.vtd-trade-item--click {
cursor: pointer;
}
.vtd-trade-item--click:hover {
border-color: color-mix(in srgb, var(--accent, #3f7ef5) 40%, var(--se-border));
background: color-mix(in srgb, var(--accent, #3f7ef5) 6%, var(--se-panel-card-bg));
}
.vtd-trade-item--click:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent, #3f7ef5) 55%, transparent);
outline-offset: 2px;
}
.vtd-trade-item--buy {
border-color: color-mix(in srgb, var(--up, #ef5350) 22%, var(--se-border));
}
.vtd-trade-item--sell {
border-color: color-mix(in srgb, var(--down, #26a69a) 22%, var(--se-border));
}
.vtd-trade-side-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.vtd-trade-side-badge--buy {
color: var(--up, #ef5350);
background: color-mix(in srgb, var(--up, #ef5350) 14%, transparent);
border: 1px solid color-mix(in srgb, var(--up, #ef5350) 28%, transparent);
}
.vtd-trade-side-badge--sell {
color: var(--down, #26a69a);
background: color-mix(in srgb, var(--down, #26a69a) 14%, transparent);
border: 1px solid color-mix(in srgb, var(--down, #26a69a) 28%, transparent);
}
.vtd-trade-quote {
margin-top: 8px;
}
.vtd-trade-meta-field {
margin-top: 6px;
}
.vtd-trade-meta-field:first-of-type {
margin-top: 8px;
}
.vtd-trade-meta-value {
flex: 1;
min-width: 0;
padding: 5px 8px;
border-radius: 8px;
border: 1px solid var(--se-border);
background: color-mix(in srgb, var(--se-panel-card-bg) 88%, var(--bg2, #12161c));
color: var(--se-text);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vtd-trade-meta-value--auto {
color: var(--accent, #3f7ef5);
font-weight: 600;
border-color: color-mix(in srgb, var(--accent, #3f7ef5) 28%, var(--se-border));
background: color-mix(in srgb, var(--accent, #3f7ef5) 8%, var(--se-panel-card-bg));
}
.vtd-trade-meta-value--strategy {
font-weight: 600;
}
.vtd-muted { .vtd-muted {
font-size: 12px; font-size: 12px;
color: var(--se-text-muted); color: var(--se-text-muted);
+10
View File
@@ -1256,6 +1256,12 @@ export const DEFAULT_TREND_SEARCH_REQUEST: TrendSearchRequest = {
export const TREND_TIMEFRAMES = ['1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1d', '1w', '1M'] as const; export const TREND_TIMEFRAMES = ['1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1d', '1w', '1M'] as const;
export interface TrendSearchResultsResponse {
results: TrendSearchResultDto[];
timeframe?: string;
scannedAt?: string;
}
export async function scanTrendSearch(body: TrendSearchRequest): Promise<TrendSearchResultDto[]> { export async function scanTrendSearch(body: TrendSearchRequest): Promise<TrendSearchResultDto[]> {
return (await request<TrendSearchResultDto[]>('/trend-search/scan', { return (await request<TrendSearchResultDto[]>('/trend-search/scan', {
method: 'POST', method: 'POST',
@@ -1263,6 +1269,10 @@ export async function scanTrendSearch(body: TrendSearchRequest): Promise<TrendSe
})) ?? []; })) ?? [];
} }
export async function fetchTrendSearchResults(): Promise<TrendSearchResultsResponse> {
return (await request<TrendSearchResultsResponse>('/trend-search/results')) ?? { results: [] };
}
export async function fetchTrendSearchDetail( export async function fetchTrendSearchDetail(
market: string, market: string,
timeframe?: string, timeframe?: string,
+9 -1
View File
@@ -1,7 +1,11 @@
/** /**
* 추세검색 API * 추세검색 API
*/ */
import { scanTrendSearch as apiScan, fetchTrendSearchDetail as apiDetail } from './backendApi'; import {
scanTrendSearch as apiScan,
fetchTrendSearchDetail as apiDetail,
fetchTrendSearchResults as apiResults,
} from './backendApi';
export type { export type {
TrendSearchConditionDto, TrendSearchConditionDto,
@@ -18,3 +22,7 @@ export async function scanTrendSearch(body: import('./backendApi').TrendSearchRe
export async function fetchTrendSearchDetail(market: string, timeframe?: string) { export async function fetchTrendSearchDetail(market: string, timeframe?: string) {
return apiDetail(market, timeframe); return apiDetail(market, timeframe);
} }
export async function loadTrendSearchResults() {
return apiResults();
}
@@ -20,6 +20,8 @@ export interface TrendSearchAppSettings {
limit: number; limit: number;
/** 스캔 대상 종목 수 (거래대금 상위) */ /** 스캔 대상 종목 수 (거래대금 상위) */
scanLimit: number; scanLimit: number;
/** 자동 갱신 ON (백엔드 스케줄러·화면 폴링) */
autoRefreshEnabled: boolean;
/** 자동 갱신 간격(초) */ /** 자동 갱신 간격(초) */
autoRefreshSeconds: number; autoRefreshSeconds: number;
/** 검색 후 상위 N위까지 투자대상 자동추가 */ /** 검색 후 상위 N위까지 투자대상 자동추가 */
@@ -38,6 +40,7 @@ export const DEFAULT_TREND_SEARCH_APP_SETTINGS: TrendSearchAppSettings = {
minTrendScore: DEFAULT_TREND_SEARCH_REQUEST.minTrendScore, minTrendScore: DEFAULT_TREND_SEARCH_REQUEST.minTrendScore,
limit: DEFAULT_TREND_SEARCH_REQUEST.limit, limit: DEFAULT_TREND_SEARCH_REQUEST.limit,
scanLimit: DEFAULT_TREND_SEARCH_REQUEST.scanLimit, scanLimit: DEFAULT_TREND_SEARCH_REQUEST.scanLimit,
autoRefreshEnabled: true,
autoRefreshSeconds: 3, autoRefreshSeconds: 3,
autoAddTargetsEnabled: false, autoAddTargetsEnabled: false,
autoAddTopRank: 5, autoAddTopRank: 5,
@@ -65,6 +68,7 @@ export function resolveTrendSearchAppSettings(
minTrendScore: clampInt(raw.minTrendScore, 0, 100, d.minTrendScore), minTrendScore: clampInt(raw.minTrendScore, 0, 100, d.minTrendScore),
limit: clampInt(limit, 5, 50, d.limit), limit: clampInt(limit, 5, 50, d.limit),
scanLimit: clampInt(scanLimit, 20, 120, d.scanLimit), scanLimit: clampInt(scanLimit, 20, 120, d.scanLimit),
autoRefreshEnabled: raw.autoRefreshEnabled !== false,
autoRefreshSeconds: TREND_SEARCH_REFRESH_OPTIONS.includes(sec as typeof TREND_SEARCH_REFRESH_OPTIONS[number]) autoRefreshSeconds: TREND_SEARCH_REFRESH_OPTIONS.includes(sec as typeof TREND_SEARCH_REFRESH_OPTIONS[number])
? sec ? sec
: d.autoRefreshSeconds, : d.autoRefreshSeconds,
@@ -126,3 +130,27 @@ export function formatTrendRefreshLabel(seconds: number): string {
if (seconds < 60) return `${seconds}`; if (seconds < 60) return `${seconds}`;
return `${seconds / 60}`; return `${seconds / 60}`;
} }
/** 설정 변경 감지용 (useEffect 의존성) */
export function trendSearchSettingsSignature(s: TrendSearchAppSettings): string {
return JSON.stringify(s);
}
/** 캐시/설정 → 추세검색 조건 패널 필드 병합 (timeframe 등 화면 전용 값 유지) */
export function mergeFiltersFromTrendSearchSettings(
filters: TrendSearchRequest,
settings: TrendSearchAppSettings,
): TrendSearchRequest {
return mergeTrendSearchRequest(filters, settings);
}
/** 추세검색 조건 변경 → 저장할 전체 추세설정 */
export function mergeTrendSearchSettingsFromRequest(
current: TrendSearchAppSettings,
req: Pick<TrendSearchRequest, BullishWeightKey | 'minTrendScore' | 'limit' | 'scanLimit'>,
): TrendSearchAppSettings {
return resolveTrendSearchAppSettings({
...current,
...extractTrendSearchAppSettings(req),
});
}