추세검색 설정 추가

This commit is contained in:
Macbook
2026-05-27 01:36:06 +09:00
parent c1bcf88c6c
commit 2e08c6b16f
42 changed files with 1507 additions and 226 deletions
@@ -9,6 +9,7 @@ public final class MenuIds {
public static final List<String> ALL = List.of(
"dashboard", "chart", "paper", "virtual", "trend-search", "strategy", "strategy-editor", "backtest", "notifications", "settings",
"settings_general", "settings_chart", "settings_indicators", "settings_backtest",
"settings_strategy", "settings_paper", "settings_alert", "settings_network", "settings_admin"
"settings_strategy", "settings_paper", "settings_trend-search",
"settings_alert", "settings_network", "settings_admin"
);
}
@@ -50,4 +50,8 @@ public class LiveStrategySettingsDto {
/** true면 gc_app_settings 전역 템플릿 갱신 생략 */
private Boolean skipGlobalTemplate;
/** 가상투자 투자대상 고정 — ON 이면 목록에서 삭제 불가 (JSON: isPinned) */
@JsonProperty("isPinned")
private Boolean pinned;
}
@@ -184,6 +184,11 @@ public class GcAppSettings {
@Builder.Default
private BigDecimal paperAutoTradeBudgetPct = BigDecimal.valueOf(95);
/** 가상매매 투자대상 목록 최대 종목 수 */
@Column(name = "virtual_target_max_count", nullable = false)
@Builder.Default
private Integer virtualTargetMaxCount = 20;
/** PAPER | LIVE | BOTH — 자동매매 실행 대상 */
@Column(name = "trading_mode", nullable = false, length = 10)
@Builder.Default
@@ -213,6 +218,11 @@ public class GcAppSettings {
@Builder.Default
private Boolean fcmPushEnabled = false;
/** 추세검색 기본 설정 JSON */
@Column(name = "trend_search_settings_json", columnDefinition = "JSON")
@JdbcTypeCode(SqlTypes.JSON)
private String trendSearchSettingsJson;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@@ -56,6 +56,11 @@ public class GcLiveStrategySettings {
@Builder.Default
private String positionMode = "LONG_ONLY";
/** 가상투자 투자대상 고정 — ON 이면 목록에서 삭제 불가 */
@Column(name = "is_pinned", nullable = false)
@Builder.Default
private Boolean isPinned = false;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@@ -138,6 +138,10 @@ public class AppSettingsService {
Boolean.parseBoolean(d.get("paperAutoTradeEnabled").toString()));
if (d.containsKey("paperAutoTradeBudgetPct")) s.setPaperAutoTradeBudgetPct(
new java.math.BigDecimal(d.get("paperAutoTradeBudgetPct").toString()));
if (d.containsKey("virtualTargetMaxCount")) {
int max = Integer.parseInt(d.get("virtualTargetMaxCount").toString());
s.setVirtualTargetMaxCount(Math.min(100, Math.max(1, max)));
}
if (d.containsKey("tradingMode")) {
String mode = d.get("tradingMode").toString().toUpperCase();
s.setTradingMode("LIVE".equals(mode) || "BOTH".equals(mode) ? mode : "PAPER");
@@ -164,6 +168,9 @@ public class AppSettingsService {
new java.math.BigDecimal(d.get("liveAutoTradeBudgetPct").toString()));
if (d.containsKey("fcmPushEnabled")) s.setFcmPushEnabled(
Boolean.parseBoolean(d.get("fcmPushEnabled").toString()));
if (d.containsKey("trendSearchSettings")) {
s.setTrendSearchSettingsJson(toJson(d.get("trendSearchSettings")));
}
}
private Map<String, Object> toMap(GcAppSettings s) {
@@ -202,6 +209,8 @@ public class AppSettingsService {
m.put("paperAutoTradeEnabled", s.getPaperAutoTradeEnabled() != null ? s.getPaperAutoTradeEnabled() : false);
m.put("paperAutoTradeBudgetPct", s.getPaperAutoTradeBudgetPct() != null
? s.getPaperAutoTradeBudgetPct().doubleValue() : 95);
m.put("virtualTargetMaxCount", s.getVirtualTargetMaxCount() != null
? s.getVirtualTargetMaxCount() : 20);
m.put("tradingMode", s.getTradingMode() != null ? s.getTradingMode() : "PAPER");
m.put("liveAutoTradeEnabled", s.getLiveAutoTradeEnabled() != null ? s.getLiveAutoTradeEnabled() : false);
UpbitApiCredentials creds = resolveUpbitCredentials(s);
@@ -211,6 +220,7 @@ public class AppSettingsService {
m.put("liveAutoTradeBudgetPct", s.getLiveAutoTradeBudgetPct() != null
? s.getLiveAutoTradeBudgetPct().doubleValue() : 95);
m.put("fcmPushEnabled", s.getFcmPushEnabled() != null ? s.getFcmPushEnabled() : false);
m.put("trendSearchSettings", parseJson(s.getTrendSearchSettingsJson()));
return m;
}
@@ -224,6 +224,9 @@ public class LiveStrategySettingsService {
"REALTIME_TICK".equals(dto.getExecutionType()) ? "REALTIME_TICK" : "CANDLE_CLOSE");
entity.setPositionMode(
"SIGNAL_ONLY".equals(dto.getPositionMode()) ? "SIGNAL_ONLY" : "LONG_ONLY");
if (dto.getPinned() != null) {
entity.setIsPinned(Boolean.TRUE.equals(dto.getPinned()));
}
repo.save(entity);
liveStrategyEvaluator.invalidateCache(market);
@@ -278,6 +281,7 @@ public class LiveStrategySettingsService {
.liveCheck(Boolean.TRUE.equals(e.getIsLiveCheck()))
.executionType(e.getExecutionType())
.positionMode(e.getPositionMode() != null ? e.getPositionMode() : "LONG_ONLY")
.pinned(Boolean.TRUE.equals(e.getIsPinned()))
.build();
enrichStrategyTimeframes(dto);
return dto;
@@ -64,10 +64,11 @@ public class TrendSearchService {
int wantBars = wantBarsForScan(r);
int minBars = requiredMinBars(r);
List<String> markets = fetchKrwMarketsByVolume(scanLimit);
KrwMarketSlice slice = fetchKrwMarketsByVolumeSlice(scanLimit);
List<String> markets = slice.markets();
if (markets.isEmpty()) return List.of();
Map<String, TickerSnap> tickers = fetchTickers(markets);
Map<String, TickerSnap> tickers = fetchTickers(markets, slice.koreanNames());
int minMatchRate = Math.min(100, Math.max(0, r.getMinMatchRate()));
int enabledFilters = countEnabledFilters(r);
boolean useBullishScore = r.isBullishTrendEnabled();
@@ -84,7 +85,8 @@ public class TrendSearchService {
BarSeries series = toSeries(candles, tf);
if (series.getBarCount() < minBars) continue;
TickerSnap tick = tickers.getOrDefault(market, TickerSnap.empty(market));
TickerSnap tick = tickers.getOrDefault(market,
TickerSnap.empty(market, slice.koreanNames().get(market)));
MetricValues m = computeMetrics(series, candles, tick, r);
if (useBullishScore) {
m.bullishTrendScore = computeBullishTrendScore(m, r);
@@ -133,8 +135,9 @@ public class TrendSearchService {
.market(market).matchRate(0).conditions(List.of()).build();
}
Map<String, TickerSnap> tickers = fetchTickers(List.of(market));
TickerSnap tick = tickers.getOrDefault(market, TickerSnap.empty(market));
Map<String, String> koreanNames = fetchKoreanNamesForMarkets(List.of(market));
Map<String, TickerSnap> tickers = fetchTickers(List.of(market), koreanNames);
TickerSnap tick = tickers.getOrDefault(market, TickerSnap.empty(market, koreanNames.get(market)));
BarSeries series = toSeries(candles, tf);
MetricValues m = computeMetrics(series, candles, tick, r);
if (r.isBullishTrendEnabled()) {
@@ -675,7 +678,37 @@ public class TrendSearchService {
// ── Upbit helpers ───────────────────────────────────────────────────────
private List<String> fetchKrwMarketsByVolume(int limit) {
private record KrwMarketSlice(List<String> markets, Map<String, String> koreanNames) {}
/** 거래대금 상위 KRW 마켓 + 한글명 (market/all 기준) */
private KrwMarketSlice fetchKrwMarketsByVolumeSlice(int limit) {
try {
Map<String, String> allNames = fetchKoreanNamesFromMarketAll();
if (allNames.isEmpty()) return new KrwMarketSlice(List.of(), Map.of());
List<String> krw = new ArrayList<>(allNames.keySet());
Map<String, TickerSnap> tickers = fetchTickers(krw, allNames);
List<String> sorted = krw.stream()
.sorted(Comparator.comparingDouble((String m) ->
tickers.getOrDefault(m, TickerSnap.empty(m, allNames.get(m))).accTradePrice24h).reversed())
.limit(limit)
.collect(Collectors.toList());
Map<String, String> names = new HashMap<>();
for (String m : sorted) {
names.put(m, allNames.getOrDefault(m, m.replace("KRW-", "")));
}
return new KrwMarketSlice(sorted, names);
} catch (Exception e) {
log.warn("[TrendSearch] market fetch fail: {}", e.getMessage());
List<String> fallback = List.of("KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-ADA");
Map<String, String> names = new HashMap<>();
for (String m : fallback) names.put(m, m.replace("KRW-", ""));
return new KrwMarketSlice(fallback, names);
}
}
private Map<String, String> fetchKoreanNamesFromMarketAll() {
try {
WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build();
String marketsJson = client.get()
@@ -683,29 +716,36 @@ public class TrendSearchService {
.retrieve()
.bodyToMono(String.class)
.block();
if (marketsJson == null) return List.of();
if (marketsJson == null) return Map.of();
List<Map<String, Object>> all = objectMapper.readValue(marketsJson, new TypeReference<>() {});
List<String> krw = all.stream()
.map(m -> (String) m.get("market"))
.filter(m -> m != null && m.startsWith("KRW-"))
.collect(Collectors.toList());
if (krw.isEmpty()) return List.of();
Map<String, TickerSnap> tickers = fetchTickers(krw);
return krw.stream()
.sorted(Comparator.comparingDouble((String m) ->
tickers.getOrDefault(m, TickerSnap.empty(m)).accTradePrice24h).reversed())
.limit(limit)
.collect(Collectors.toList());
Map<String, String> names = new HashMap<>();
for (Map<String, Object> m : all) {
String market = (String) m.get("market");
if (market == null || !market.startsWith("KRW-")) continue;
Object kn = m.get("korean_name");
if (kn != null && !kn.toString().isBlank()) {
names.put(market, kn.toString());
}
}
return names;
} catch (Exception e) {
log.warn("[TrendSearch] market fetch fail: {}", e.getMessage());
return List.of("KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-ADA");
log.warn("[TrendSearch] korean names fetch fail: {}", e.getMessage());
return Map.of();
}
}
private Map<String, TickerSnap> fetchTickers(List<String> markets) {
private Map<String, String> fetchKoreanNamesForMarkets(List<String> markets) {
if (markets.isEmpty()) return Map.of();
Map<String, String> all = fetchKoreanNamesFromMarketAll();
Map<String, String> out = new HashMap<>();
for (String m : markets) {
out.put(m, all.getOrDefault(m, m.replace("KRW-", "")));
}
return out;
}
private Map<String, TickerSnap> fetchTickers(List<String> markets, Map<String, String> koreanNames) {
if (markets.isEmpty()) return Map.of();
try {
WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build();
@@ -722,9 +762,12 @@ public class TrendSearchService {
JsonNode arr = objectMapper.readTree(json);
for (JsonNode n : arr) {
String market = n.path("market").asText();
String ko = koreanNames != null
? koreanNames.getOrDefault(market, market.replace("KRW-", ""))
: market.replace("KRW-", "");
map.put(market, new TickerSnap(
market,
n.path("korean_name").asText(market),
ko,
n.path("trade_price").asDouble(0),
n.path("signed_change_rate").asDouble(0) * 100,
n.path("acc_trade_price_24h").asDouble(0)
@@ -777,8 +820,11 @@ public class TrendSearchService {
private record TickerSnap(String market, String koreanName, double tradePrice,
double changeRate, double accTradePrice24h) {
static TickerSnap empty(String market) {
return new TickerSnap(market, market.replace("KRW-", ""), 0, 0, 0);
static TickerSnap empty(String market, String koreanName) {
String ko = koreanName != null && !koreanName.isBlank()
? koreanName
: market.replace("KRW-", "");
return new TickerSnap(market, ko, 0, 0, 0);
}
}