diff --git a/backend/src/main/java/com/goldenchart/auth/MenuIds.java b/backend/src/main/java/com/goldenchart/auth/MenuIds.java index 25674c3..ed35f72 100644 --- a/backend/src/main/java/com/goldenchart/auth/MenuIds.java +++ b/backend/src/main/java/com/goldenchart/auth/MenuIds.java @@ -9,6 +9,7 @@ public final class MenuIds { public static final List 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" ); } diff --git a/backend/src/main/java/com/goldenchart/dto/LiveStrategySettingsDto.java b/backend/src/main/java/com/goldenchart/dto/LiveStrategySettingsDto.java index 335fdaf..29c7745 100644 --- a/backend/src/main/java/com/goldenchart/dto/LiveStrategySettingsDto.java +++ b/backend/src/main/java/com/goldenchart/dto/LiveStrategySettingsDto.java @@ -50,4 +50,8 @@ public class LiveStrategySettingsDto { /** true면 gc_app_settings 전역 템플릿 갱신 생략 */ private Boolean skipGlobalTemplate; + + /** 가상투자 투자대상 고정 — ON 이면 목록에서 삭제 불가 (JSON: isPinned) */ + @JsonProperty("isPinned") + private Boolean pinned; } diff --git a/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java b/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java index b98a2d8..5f6dfa0 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java +++ b/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java @@ -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; diff --git a/backend/src/main/java/com/goldenchart/entity/GcLiveStrategySettings.java b/backend/src/main/java/com/goldenchart/entity/GcLiveStrategySettings.java index 8928836..28973b3 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcLiveStrategySettings.java +++ b/backend/src/main/java/com/goldenchart/entity/GcLiveStrategySettings.java @@ -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; diff --git a/backend/src/main/java/com/goldenchart/service/AppSettingsService.java b/backend/src/main/java/com/goldenchart/service/AppSettingsService.java index 0fdc53f..b797c90 100644 --- a/backend/src/main/java/com/goldenchart/service/AppSettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/AppSettingsService.java @@ -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 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; } diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java b/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java index a876778..b872535 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java @@ -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; diff --git a/backend/src/main/java/com/goldenchart/service/TrendSearchService.java b/backend/src/main/java/com/goldenchart/service/TrendSearchService.java index 44a6788..2469826 100644 --- a/backend/src/main/java/com/goldenchart/service/TrendSearchService.java +++ b/backend/src/main/java/com/goldenchart/service/TrendSearchService.java @@ -64,10 +64,11 @@ public class TrendSearchService { int wantBars = wantBarsForScan(r); int minBars = requiredMinBars(r); - List markets = fetchKrwMarketsByVolume(scanLimit); + KrwMarketSlice slice = fetchKrwMarketsByVolumeSlice(scanLimit); + List markets = slice.markets(); if (markets.isEmpty()) return List.of(); - Map tickers = fetchTickers(markets); + Map 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 tickers = fetchTickers(List.of(market)); - TickerSnap tick = tickers.getOrDefault(market, TickerSnap.empty(market)); + Map koreanNames = fetchKoreanNamesForMarkets(List.of(market)); + Map 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 fetchKrwMarketsByVolume(int limit) { + private record KrwMarketSlice(List markets, Map koreanNames) {} + + /** 거래대금 상위 KRW 마켓 + 한글명 (market/all 기준) */ + private KrwMarketSlice fetchKrwMarketsByVolumeSlice(int limit) { + try { + Map allNames = fetchKoreanNamesFromMarketAll(); + if (allNames.isEmpty()) return new KrwMarketSlice(List.of(), Map.of()); + + List krw = new ArrayList<>(allNames.keySet()); + Map tickers = fetchTickers(krw, allNames); + List 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 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 fallback = List.of("KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-ADA"); + Map names = new HashMap<>(); + for (String m : fallback) names.put(m, m.replace("KRW-", "")); + return new KrwMarketSlice(fallback, names); + } + } + + private Map 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> all = objectMapper.readValue(marketsJson, new TypeReference<>() {}); - List 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 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 names = new HashMap<>(); + for (Map 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 fetchTickers(List markets) { + private Map fetchKoreanNamesForMarkets(List markets) { + if (markets.isEmpty()) return Map.of(); + Map all = fetchKoreanNamesFromMarketAll(); + Map out = new HashMap<>(); + for (String m : markets) { + out.put(m, all.getOrDefault(m, m.replace("KRW-", ""))); + } + return out; + } + + private Map fetchTickers(List markets, Map 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); } } diff --git a/backend/src/main/resources/db/migration/V31__live_strategy_is_pinned.sql b/backend/src/main/resources/db/migration/V31__live_strategy_is_pinned.sql new file mode 100644 index 0000000..261a470 --- /dev/null +++ b/backend/src/main/resources/db/migration/V31__live_strategy_is_pinned.sql @@ -0,0 +1,5 @@ +-- 가상투자 투자대상 고정(삭제 방지) +ALTER TABLE gc_live_strategy_settings + ADD COLUMN is_pinned TINYINT(1) NOT NULL DEFAULT 0 + COMMENT '투자대상 고정 ON(1)=목록에서 삭제 불가' + AFTER position_mode; diff --git a/backend/src/main/resources/db/migration/V32__virtual_target_max_count.sql b/backend/src/main/resources/db/migration/V32__virtual_target_max_count.sql new file mode 100644 index 0000000..aeb859b --- /dev/null +++ b/backend/src/main/resources/db/migration/V32__virtual_target_max_count.sql @@ -0,0 +1,5 @@ +-- 가상매매 투자대상 목록 최대 개수 +ALTER TABLE gc_app_settings + ADD COLUMN virtual_target_max_count INT NOT NULL DEFAULT 20 + COMMENT '가상매매 투자대상 목록 최대 종목 수' + AFTER paper_auto_trade_budget_pct; diff --git a/backend/src/main/resources/db/migration/V33__trend_search_settings_json.sql b/backend/src/main/resources/db/migration/V33__trend_search_settings_json.sql new file mode 100644 index 0000000..442e5c8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V33__trend_search_settings_json.sql @@ -0,0 +1,5 @@ +-- 추세검색 기본 설정 (배점·합격점·결과수·자동갱신·자동추가 등) +ALTER TABLE gc_app_settings + ADD COLUMN trend_search_settings_json JSON NULL + COMMENT '추세검색 화면 기본 설정 JSON' + AFTER virtual_target_max_count; diff --git a/backend/src/main/resources/db/migration/V34__settings_trend_search_menu.sql b/backend/src/main/resources/db/migration/V34__settings_trend_search_menu.sql new file mode 100644 index 0000000..7dd4e79 --- /dev/null +++ b/backend/src/main/resources/db/migration/V34__settings_trend_search_menu.sql @@ -0,0 +1,6 @@ +-- 설정 · 추세검색 메뉴 권한 +INSERT INTO gc_role_menu_permission (role, menu_id, allowed) VALUES +('ADMIN', 'settings_trend-search', 1), +('USER', 'settings_trend-search', 1), +('GUEST', 'settings_trend-search', 0) +ON DUPLICATE KEY UPDATE allowed = VALUES(allowed); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1d02d50..386a64e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -52,6 +52,8 @@ import { splitIndicatorPane, } from './utils/indicatorPaneMerge'; import { useAppSettings } from './hooks/useAppSettings'; +import { clampVirtualTargetMax } from './utils/virtualTargetLimits'; +import { resolveTrendSearchAppSettings } from './utils/trendSearchAppSettings'; import MarketPanel from './components/MarketPanel'; import TopMenuBar, { type MenuPage } from './components/TopMenuBar'; import StrategyPage from './components/StrategyPage'; @@ -1723,6 +1725,12 @@ function App() { onPaperAutoTradeEnabled={v => saveAppDef({ paperAutoTradeEnabled: v })} paperAutoTradeBudgetPct={appDefaults.paperAutoTradeBudgetPct} onPaperAutoTradeBudgetPct={v => saveAppDef({ paperAutoTradeBudgetPct: v })} + virtualTargetMaxCount={appDefaults.virtualTargetMaxCount} + onVirtualTargetMaxCount={v => saveAppDef({ virtualTargetMaxCount: clampVirtualTargetMax(v) })} + trendSearchSettings={appDefaults.trendSearchSettings} + onTrendSearchSettingsChange={s => saveAppDef({ + trendSearchSettings: resolveTrendSearchAppSettings(s), + })} onPaperAccountReset={async () => { await resetPaperAccount(); await refreshPaperAccount(); diff --git a/frontend/src/components/MarketSearchPanel.tsx b/frontend/src/components/MarketSearchPanel.tsx index 9b27aab..2c652fe 100644 --- a/frontend/src/components/MarketSearchPanel.tsx +++ b/frontend/src/components/MarketSearchPanel.tsx @@ -65,12 +65,14 @@ interface MarketSearchPanelProps { onAdd?: (market: string, meta: { koreanName: string; englishName: string }) => void; /** 이미 추가된 종목 (add 모드에서 + 비활성) */ addedMarkets?: Set; + /** 투자대상 최대 개수에 도달 — 미등록 종목 추가 불가 */ + maxTargetsReached?: boolean; } export const MarketSearchPanel: React.FC = ({ currentMarket, onSelect, onClose, anchorRect, anchorPlacement = 'slot', initialQuery = '', variant = 'overlay', query: controlledQuery, onQueryChange, - actionMode = 'favorite', onAdd, addedMarkets, + actionMode = 'favorite', onAdd, addedMarkets, maxTargetsReached = false, }) => { const embedded = variant === 'embedded'; const [markets, setMarkets] = useState([]); @@ -186,6 +188,7 @@ export const MarketSearchPanel: React.FC = ({ if (embedded) onClose(); return; } + if (maxTargetsReached) return; const row = markets.find(m => m.market === market); onAdd?.(market, { koreanName: row?.korean_name ?? market, @@ -196,7 +199,7 @@ export const MarketSearchPanel: React.FC = ({ } onSelect(market); if (!embedded) onClose(); - }, [actionMode, onSelect, onAdd, addedMarkets, markets, embedded, onClose]); + }, [actionMode, onSelect, onAdd, addedMarkets, maxTargetsReached, markets, embedded, onClose]); const handleAddClick = useCallback((market: string, e: React.MouseEvent) => { e.stopPropagation(); @@ -374,6 +377,7 @@ export const MarketSearchPanel: React.FC = ({ const tk = tickers[m.market]; const isFav = favs.has(m.market); const isAdded = addedMarkets?.has(m.market) ?? false; + const addBlocked = maxTargetsReached && !isAdded; const isActive = m.market === currentMarket; const rate = tk ? formatChangeRate(tk.signed_change_rate) : null; const isUp = rate !== null && rate >= 0; @@ -393,10 +397,16 @@ export const MarketSearchPanel: React.FC = ({ actionMode === 'add' ? ( + + +); + +export default TrendSearchTargetActions; diff --git a/frontend/src/components/trendSearch/TrendSearchViewHeaderControls.tsx b/frontend/src/components/trendSearch/TrendSearchViewHeaderControls.tsx index 650f3ed..bbb8145 100644 --- a/frontend/src/components/trendSearch/TrendSearchViewHeaderControls.tsx +++ b/frontend/src/components/trendSearch/TrendSearchViewHeaderControls.tsx @@ -1,11 +1,19 @@ import React from 'react'; import type { TrendSearchDisplayMode } from './TrendSearchResultCard'; +import { + TREND_SEARCH_REFRESH_OPTIONS, + formatTrendRefreshLabel, +} from '../../utils/trendSearchAppSettings'; interface Props { displayMode: TrendSearchDisplayMode; onDisplayModeChange: (mode: TrendSearchDisplayMode) => void; autoRefresh: boolean; onAutoRefreshChange: (v: boolean) => void; + refreshSeconds: number; + onRefreshSecondsChange: (sec: number) => void; + autoAddTargets: boolean; + onAutoAddTargetsChange: (v: boolean) => void; searching?: boolean; onRefresh: () => void; resultCount?: number; @@ -16,6 +24,10 @@ const TrendSearchViewHeaderControls: React.FC = ({ onDisplayModeChange, autoRefresh, onAutoRefreshChange, + refreshSeconds, + onRefreshSecondsChange, + autoAddTargets, + onAutoAddTargetsChange, searching, onRefresh, resultCount = 0, @@ -42,10 +54,34 @@ const TrendSearchViewHeaderControls: React.FC = ({