From 67324ded9de9bb6773e96efcaa1d15f3d497f668 Mon Sep 17 00:00:00 2001 From: Macbook Date: Mon, 25 May 2026 03:15:05 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=EC=84=A4=EB=AA=85=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/LiveStrategyStartupRunner.java | 31 +- .../controller/StrategyController.java | 8 + .../dto/LiveStrategySettingsDto.java | 10 +- .../service/LiveStrategyEvaluator.java | 10 +- .../service/LiveStrategySettingsService.java | 91 ++--- .../StrategyConditionTimeframeService.java | 113 ++++++ .../goldenchart/service/StrategyService.java | 6 +- .../com/goldenchart/websocket/BarBuilder.java | 8 +- .../websocket/LiveStrategyScheduler.java | 21 +- .../ComplexStrategyDslAdapterTest.java | 336 +++++++++++++++++ frontend/src/App.tsx | 47 ++- frontend/src/components/ChartSlot.tsx | 11 +- frontend/src/components/LiveStrategyPanel.tsx | 23 +- frontend/src/components/SettingsPage.tsx | 16 - .../src/components/StrategyEditorPage.tsx | 31 ++ frontend/src/components/TradingChart.tsx | 25 +- .../components/paper/PaperLeftStrategyTab.tsx | 23 +- .../StrategyDescriptionModal.tsx | 85 +++++ frontend/src/hooks/useStompChartData.ts | 1 - frontend/src/styles/strategyEditor.css | 76 ++++ frontend/src/utils/backendApi.ts | 16 +- .../src/utils/strategyDescriptionNarrative.ts | 350 ++++++++++++++++++ 22 files changed, 1151 insertions(+), 187 deletions(-) create mode 100644 backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java create mode 100644 backend/src/test/java/com/goldenchart/service/ComplexStrategyDslAdapterTest.java create mode 100644 frontend/src/components/strategyEditor/StrategyDescriptionModal.tsx create mode 100644 frontend/src/utils/strategyDescriptionNarrative.ts diff --git a/backend/src/main/java/com/goldenchart/config/LiveStrategyStartupRunner.java b/backend/src/main/java/com/goldenchart/config/LiveStrategyStartupRunner.java index 89ca38a..dd0b859 100644 --- a/backend/src/main/java/com/goldenchart/config/LiveStrategyStartupRunner.java +++ b/backend/src/main/java/com/goldenchart/config/LiveStrategyStartupRunner.java @@ -1,13 +1,11 @@ package com.goldenchart.config; import com.goldenchart.entity.GcAppSettings; -import com.goldenchart.entity.GcLiveStrategySettings; import com.goldenchart.entity.GcWatchlist; -import com.goldenchart.repository.GcLiveStrategySettingsRepository; import com.goldenchart.repository.GcWatchlistRepository; import com.goldenchart.service.AppSettingsService; import com.goldenchart.service.LiveStrategyEvaluator; -import com.goldenchart.service.LiveStrategyTimeframeService; +import com.goldenchart.service.StrategyConditionTimeframeService; import com.goldenchart.websocket.DynamicSubscriptionManager; import com.goldenchart.websocket.UpbitWebSocketClient; import lombok.RequiredArgsConstructor; @@ -27,9 +25,9 @@ public class LiveStrategyStartupRunner implements ApplicationRunner { private final AppSettingsService appSettingsService; private final GcWatchlistRepository watchlistRepo; - private final GcLiveStrategySettingsRepository liveSettingsRepo; private final DynamicSubscriptionManager subscriptionManager; private final LiveStrategyEvaluator liveStrategyEvaluator; + private final StrategyConditionTimeframeService conditionTimeframes; private final UpbitWebSocketClient upbitWebSocketClient; @Override @@ -44,11 +42,7 @@ public class LiveStrategyStartupRunner implements ApplicationRunner { if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) || app.getLiveStrategyId() == null) { continue; } - String ct = resolvePinnedCandleType(w); - subscriptionManager.ensureMarketPinned(w.getSymbol(), "1m"); - if (!"1m".equals(ct)) { - subscriptionManager.ensureMarketPinned(w.getSymbol(), ct); - } + pinStrategyTimeframes(w.getSymbol(), app.getLiveStrategyId()); liveStrategyEvaluator.invalidateCache(w.getSymbol()); pinned++; } @@ -59,19 +53,12 @@ public class LiveStrategyStartupRunner implements ApplicationRunner { upbitWebSocketClient.resubscribeAll(); } - private String resolvePinnedCandleType(GcWatchlist w) { - if (w.getUserId() != null) { - return liveSettingsRepo.findByUserIdAndMarket(w.getUserId(), w.getSymbol()) - .map(GcLiveStrategySettings::getCandleType) - .map(LiveStrategyTimeframeService::normalize) - .orElse("1m"); + private void pinStrategyTimeframes(String market, Long strategyId) { + subscriptionManager.ensureMarketPinned(market, "1m"); + for (String ct : conditionTimeframes.collectForStrategy(strategyId)) { + if (!"1m".equals(ct)) { + subscriptionManager.ensureMarketPinned(market, ct); + } } - if (w.getDeviceId() != null) { - return liveSettingsRepo.findByDeviceIdAndMarket(w.getDeviceId(), w.getSymbol()) - .map(GcLiveStrategySettings::getCandleType) - .map(LiveStrategyTimeframeService::normalize) - .orElse("1m"); - } - return "1m"; } } diff --git a/backend/src/main/java/com/goldenchart/controller/StrategyController.java b/backend/src/main/java/com/goldenchart/controller/StrategyController.java index 1230b63..0afc2d1 100644 --- a/backend/src/main/java/com/goldenchart/controller/StrategyController.java +++ b/backend/src/main/java/com/goldenchart/controller/StrategyController.java @@ -1,6 +1,7 @@ package com.goldenchart.controller; import com.goldenchart.dto.StrategyDto; +import com.goldenchart.service.StrategyConditionTimeframeService; import com.goldenchart.service.StrategyService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -23,6 +24,7 @@ import java.util.List; public class StrategyController { private final StrategyService service; + private final StrategyConditionTimeframeService conditionTimeframes; /** 전략 목록 조회 */ @GetMapping @@ -49,6 +51,12 @@ public class StrategyController { return ResponseEntity.ok(service.save(dto, parseUserId(userIdHeader), deviceId)); } + /** 전략 DSL에 포함된 평가 시간봉 목록 */ + @GetMapping("/{id}/timeframes") + public ResponseEntity> timeframes(@PathVariable Long id) { + return ResponseEntity.ok(conditionTimeframes.collectForStrategyList(id)); + } + /** 전략 삭제 */ @DeleteMapping("/{id}") public ResponseEntity delete(@PathVariable Long id) { diff --git a/backend/src/main/java/com/goldenchart/dto/LiveStrategySettingsDto.java b/backend/src/main/java/com/goldenchart/dto/LiveStrategySettingsDto.java index 3299e9f..107769b 100644 --- a/backend/src/main/java/com/goldenchart/dto/LiveStrategySettingsDto.java +++ b/backend/src/main/java/com/goldenchart/dto/LiveStrategySettingsDto.java @@ -6,6 +6,8 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.util.List; + /** * 실시간 전략 체크 설정 DTO. * PUT /api/strategy/settings (요청/응답 공용) @@ -29,10 +31,16 @@ public class LiveStrategySettingsDto { /** "CANDLE_CLOSE" | "REALTIME_TICK" */ private String executionType; - /** 전략 평가 분봉: 1m, 3m, 5m, 15m, 30m, 1h, 4h, 1d */ + /** + * @deprecated 전략 DSL의 START/TIMEFRAME에서 시간봉을 자동 추출합니다. 응답 전용 필드는 {@link #strategyCandleTypes}를 사용하세요. + */ + @Deprecated @Builder.Default private String candleType = "1m"; + /** 전략 조건 DSL에 포함된 평가 시간봉 목록 (응답 전용) */ + private List strategyCandleTypes; + /** 매도 시그널 포지션 종속성 모드: "LONG_ONLY" | "SIGNAL_ONLY" */ @Builder.Default private String positionMode = "LONG_ONLY"; diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java index 7f3bcc7..9caed90 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java @@ -55,6 +55,7 @@ public class LiveStrategyEvaluator { private final IndicatorSettingsService indicatorSettingsService; private final ObjectMapper objectMapper; private final StrategySignalDeterminer determiner; + private final StrategyConditionTimeframeService conditionTimeframes; /** * Strategy 캐시: "market:candleType:strategyId" → Strategy @@ -103,8 +104,7 @@ public class LiveStrategyEvaluator { if (!Boolean.TRUE.equals(s.getIsLiveCheck())) continue; if (!"CANDLE_CLOSE".equals(s.getExecutionType())) continue; if (s.getStrategyId() == null) continue; - if (!candleType.equals(LiveStrategyTimeframeService.normalize( - s.getCandleType() != null ? s.getCandleType() : "1m"))) continue; + if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) continue; String result = evaluate(market, candleType, s.getStrategyId(), s.getPositionMode(), maturedIndex, @@ -155,8 +155,7 @@ public class LiveStrategyEvaluator { if (!Boolean.TRUE.equals(s.getIsLiveCheck())) continue; if (!"REALTIME_TICK".equals(s.getExecutionType())) continue; if (s.getStrategyId() == null) continue; - if (!candleType.equals(LiveStrategyTimeframeService.normalize( - s.getCandleType() != null ? s.getCandleType() : "1m"))) continue; + if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) continue; // ★ Ta4j CachedIndicator 캐시 무효화: // updateLastBar 로 provisional 봉 가격이 바뀌어도 CCI 등 CachedIndicator 는 @@ -202,8 +201,7 @@ public class LiveStrategyEvaluator { if (!Boolean.TRUE.equals(s.getIsLiveCheck())) continue; if (!"REALTIME_TICK".equals(s.getExecutionType())) continue; if (s.getStrategyId() == null) continue; - if (!candleType.equals(LiveStrategyTimeframeService.normalize( - s.getCandleType() != null ? s.getCandleType() : "1m"))) continue; + if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) continue; // CachedIndicator 캐시 무효화 — 확정봉의 최종 close 로 재계산 String cacheKey = market + ":" + candleType + ":" + s.getStrategyId(); diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java b/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java index 29d5b4a..e1332aa 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java @@ -30,7 +30,7 @@ public class LiveStrategySettingsService { private final AppSettingsService appSettingsService; private final DynamicSubscriptionManager subscriptionManager; private final LiveStrategyEvaluator liveStrategyEvaluator; - private final LiveStrategyTimeframeService timeframeService; + private final StrategyConditionTimeframeService conditionTimeframes; // ── 조회 ────────────────────────────────────────────────────────────────── @@ -42,7 +42,9 @@ public class LiveStrategySettingsService { ? toDto(entity.get()) : defaultDtoFromApp(market, app); // 실시간 체크 마스터 스위치·전략 ID는 앱 전역 설정(gc_app_settings) 기준 - return mergeGlobalTemplate(dto, app, market); + dto = mergeGlobalTemplate(dto, app, market); + enrichStrategyTimeframes(dto); + return dto; } @Transactional(readOnly = true) @@ -52,21 +54,17 @@ public class LiveStrategySettingsService { return List.of(); } List watchlist = listWatchlistSymbols(userId, deviceId); + Long strategyId = app.getLiveStrategyId(); + List strategyCandleTypes = conditionTimeframes.collectForStrategyList(strategyId); return watchlist.stream() - .map(m -> { - Optional row = findEntity(userId, deviceId, m); - String ct = row.map(GcLiveStrategySettings::getCandleType) - .map(LiveStrategyTimeframeService::normalize) - .orElseGet(() -> timeframeService.resolveCandleType(m, userId, deviceId)); - return LiveStrategySettingsDto.builder() + .map(m -> LiveStrategySettingsDto.builder() .market(m) - .strategyId(app.getLiveStrategyId()) + .strategyId(strategyId) .liveCheck(true) .executionType(app.getLiveExecutionType()) .positionMode(app.getLivePositionMode()) - .candleType(ct) - .build(); - }) + .strategyCandleTypes(strategyCandleTypes) + .build()) .toList(); } @@ -77,18 +75,12 @@ public class LiveStrategySettingsService { GcAppSettings app = appSettingsService.getEntity(userId, deviceId); LiveStrategySettingsDto template = findEntity(userId, deviceId, market) .map(e -> mergeGlobalTemplate(toDto(e), app, market)) - .orElseGet(() -> { - LiveStrategySettingsDto dto = defaultDtoFromApp(market, app); - String inherited = resolveSharedCandleType(userId, deviceId); - if (inherited != null) { - dto.setCandleType(inherited); - } - return mergeGlobalTemplate(dto, app, market); - }); + .orElseGet(() -> mergeGlobalTemplate(defaultDtoFromApp(market, app), app, market)); template.setLiveCheck(true); + enrichStrategyTimeframes(template); upsertEntity(userId, deviceId, template); pinIfReady(market, template); - log.debug("[LiveStrategy] watchlist add → enabled {} candleType={}", market, template.getCandleType()); + log.debug("[LiveStrategy] watchlist add → enabled {}", market); } /** 관심종목 해제 시 — 해당 종목 전략 체크 비활성화 */ @@ -121,7 +113,6 @@ public class LiveStrategySettingsService { .liveCheck(true) .executionType(template.getExecutionType()) .positionMode(template.getPositionMode()) - .candleType(template.getCandleType() != null ? template.getCandleType() : "1m") .build(); results.add(upsertEntity(userId, deviceId, one)); } @@ -148,9 +139,7 @@ public class LiveStrategySettingsService { if (dto.getPositionMode() != null) { out.setPositionMode(dto.getPositionMode()); } - if (dto.getCandleType() != null) { - out.setCandleType(LiveStrategyTimeframeService.normalize(dto.getCandleType())); - } + enrichStrategyTimeframes(out); return out; } @@ -170,9 +159,6 @@ public class LiveStrategySettingsService { GcAppSettings app = appSettingsService.getEntity(userId, deviceId); boolean active = Boolean.TRUE.equals(app.getLiveStrategyCheck()) && app.getLiveStrategyId() != null; - String candleType = template != null && template.getCandleType() != null - ? LiveStrategyTimeframeService.normalize(template.getCandleType()) - : resolveSharedCandleType(userId, deviceId); for (String symbol : listWatchlistSymbols(userId, deviceId)) { LiveStrategySettingsDto row = findEntity(userId, deviceId, symbol) .map(e -> mergeGlobalTemplate(toDto(e), app, symbol)) @@ -183,9 +169,7 @@ public class LiveStrategySettingsService { ? app.getLiveExecutionType() : row.getExecutionType()); row.setPositionMode(app.getLivePositionMode() != null ? app.getLivePositionMode() : row.getPositionMode()); - if (candleType != null) { - row.setCandleType(candleType); - } + enrichStrategyTimeframes(row); LiveStrategySettingsDto saved = upsertEntity(userId, deviceId, row); if (active) { pinIfReady(symbol, saved); @@ -193,15 +177,21 @@ public class LiveStrategySettingsService { } } - /** 관심종목 중 이미 저장된 평가 분봉 (전역 템플릿 동기화용) */ - private String resolveSharedCandleType(Long userId, String deviceId) { - for (String symbol : listWatchlistSymbols(userId, deviceId)) { - Optional row = findEntity(userId, deviceId, symbol); - if (row.isPresent() && row.get().getCandleType() != null) { - return LiveStrategyTimeframeService.normalize(row.get().getCandleType()); + private void enrichStrategyTimeframes(LiveStrategySettingsDto dto) { + if (dto.getStrategyId() != null) { + dto.setStrategyCandleTypes( + conditionTimeframes.collectForStrategyList(dto.getStrategyId())); + } + } + + private void pinAllStrategyTimeframes(String market, Long strategyId) { + if (strategyId == null) return; + subscriptionManager.ensureMarketPinned(market, "1m"); + for (String ct : conditionTimeframes.collectForStrategy(strategyId)) { + if (!"1m".equals(ct)) { + subscriptionManager.ensureMarketPinned(market, ct); } } - return null; } private LiveStrategySettingsDto upsertEntity(Long userId, String deviceId, @@ -222,8 +212,6 @@ public class LiveStrategySettingsService { "REALTIME_TICK".equals(dto.getExecutionType()) ? "REALTIME_TICK" : "CANDLE_CLOSE"); entity.setPositionMode( "SIGNAL_ONLY".equals(dto.getPositionMode()) ? "SIGNAL_ONLY" : "LONG_ONLY"); - entity.setCandleType(LiveStrategyTimeframeService.normalize( - dto.getCandleType() != null ? dto.getCandleType() : "1m")); repo.save(entity); liveStrategyEvaluator.invalidateCache(market); @@ -232,12 +220,7 @@ public class LiveStrategySettingsService { private void pinIfReady(String market, LiveStrategySettingsDto dto) { if (Boolean.TRUE.equals(dto.isLiveCheck()) && dto.getStrategyId() != null) { - String ct = LiveStrategyTimeframeService.normalize( - dto.getCandleType() != null ? dto.getCandleType() : "1m"); - subscriptionManager.ensureMarketPinned(market, "1m"); - if (!"1m".equals(ct)) { - subscriptionManager.ensureMarketPinned(market, ct); - } + pinAllStrategyTimeframes(market, dto.getStrategyId()); } } @@ -277,14 +260,15 @@ public class LiveStrategySettingsService { } private LiveStrategySettingsDto toDto(GcLiveStrategySettings e) { - return LiveStrategySettingsDto.builder() + LiveStrategySettingsDto dto = LiveStrategySettingsDto.builder() .market(e.getMarket()) .strategyId(e.getStrategyId()) .liveCheck(Boolean.TRUE.equals(e.getIsLiveCheck())) .executionType(e.getExecutionType()) .positionMode(e.getPositionMode() != null ? e.getPositionMode() : "LONG_ONLY") - .candleType(e.getCandleType() != null ? e.getCandleType() : "1m") .build(); + enrichStrategyTimeframes(dto); + return dto; } /** 앱 전역 실시간 체크 ON/OFF·전략 ID를 DTO에 반영 (종목별 행과 분리) */ @@ -309,17 +293,6 @@ public class LiveStrategySettingsService { .liveCheck(Boolean.TRUE.equals(app.getLiveStrategyCheck())) .executionType(app.getLiveExecutionType() != null ? app.getLiveExecutionType() : "CANDLE_CLOSE") .positionMode(app.getLivePositionMode() != null ? app.getLivePositionMode() : "LONG_ONLY") - .candleType(LiveStrategyTimeframeService.normalize( - app.getDefaultTimeframe() != null - ? mapAppTf(app.getDefaultTimeframe()) : "1m")) .build(); } - - private static String mapAppTf(String tf) { - return switch (tf) { - case "1m", "3m", "5m", "15m", "30m", "1h", "4h" -> tf; - case "1D" -> "1d"; - default -> "1m"; - }; - } } diff --git a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java new file mode 100644 index 0000000..518f66f --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java @@ -0,0 +1,113 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.entity.GcStrategy; +import com.goldenchart.repository.GcStrategyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 전략 DSL(buy/sell LogicNode)에 포함된 조건 판별 시간봉 수집. + * START / TIMEFRAME / AND|OR+TIMEFRAME 분기와 동일 규칙. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class StrategyConditionTimeframeService { + + private final GcStrategyRepository strategyRepo; + private final ObjectMapper objectMapper; + + private final ConcurrentHashMap> cache = new ConcurrentHashMap<>(); + + public Set collectForStrategy(long strategyId) { + return cache.computeIfAbsent(strategyId, this::loadFromDb); + } + + public List collectForStrategyList(long strategyId) { + return List.copyOf(collectForStrategy(strategyId)); + } + + public boolean usesTimeframe(long strategyId, String candleType) { + String ct = LiveStrategyTimeframeService.normalize(candleType); + return collectForStrategy(strategyId).contains(ct); + } + + public void invalidate(long strategyId) { + cache.remove(strategyId); + } + + public void invalidateAll() { + cache.clear(); + } + + private Set loadFromDb(long strategyId) { + Optional opt = strategyRepo.findById(strategyId); + if (opt.isEmpty()) return Set.of("1m"); + GcStrategy strategy = opt.get(); + Set out = new LinkedHashSet<>(); + collectFromJson(strategy.getBuyConditionJson(), out); + collectFromJson(strategy.getSellConditionJson(), out); + return out.isEmpty() ? Set.of("1m") : out; + } + + private void collectFromJson(String json, Set out) { + if (json == null || json.isBlank()) return; + try { + collectFromNode(objectMapper.readTree(json), out); + } catch (Exception e) { + log.warn("[StrategyTimeframes] JSON 파싱 실패: {}", e.getMessage()); + out.add("1m"); + } + } + + private void collectFromNode(JsonNode node, Set out) { + if (node == null || node.isNull()) return; + String type = node.path("type").asText(""); + + if ("TIMEFRAME".equals(type)) { + out.add(LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m"))); + return; + } + + if ("AND".equals(type) || "OR".equals(type)) { + JsonNode children = node.path("children"); + if (children.isArray() && !children.isEmpty()) { + if (allTimeframeChildren(children)) { + for (JsonNode tf : children) { + out.add(LiveStrategyTimeframeService.normalize(tf.path("candleType").asText("1m"))); + } + return; + } + for (JsonNode child : children) { + collectFromNode(child, out); + } + return; + } + } + + JsonNode child = node.path("child"); + if (!child.isMissingNode() && !child.isNull()) { + collectFromNode(child, out); + return; + } + + // 단일 START(기본 1m) 또는 TIMEFRAME 래핑 없는 트리 + out.add("1m"); + } + + private static boolean allTimeframeChildren(JsonNode children) { + for (JsonNode c : children) { + if (!"TIMEFRAME".equals(c.path("type").asText(""))) return false; + } + return true; + } +} diff --git a/backend/src/main/java/com/goldenchart/service/StrategyService.java b/backend/src/main/java/com/goldenchart/service/StrategyService.java index 0a28835..3762eb2 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyService.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyService.java @@ -20,6 +20,7 @@ public class StrategyService { private final GcStrategyRepository repository; private final ObjectMapper objectMapper; + private final StrategyConditionTimeframeService conditionTimeframes; // ── 조회 ────────────────────────────────────────────────────────────────── @@ -61,13 +62,16 @@ public class StrategyService { log.warn("전략 DSL 직렬화 실패: {}", e.getMessage()); } - return toDto(repository.save(entity)); + GcStrategy saved = repository.save(entity); + conditionTimeframes.invalidate(saved.getId()); + return toDto(saved); } // ── 삭제 ────────────────────────────────────────────────────────────────── @Transactional public void delete(Long id) { + conditionTimeframes.invalidate(id); repository.deleteById(id); } diff --git a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java index a71ed5e..5aa61f9 100644 --- a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java +++ b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java @@ -5,6 +5,7 @@ import com.goldenchart.entity.GcLiveStrategySettings; import com.goldenchart.repository.GcLiveStrategySettingsRepository; import com.goldenchart.service.LiveStrategyEvaluator; import com.goldenchart.service.LiveStrategyTimeframeService; +import com.goldenchart.service.StrategyConditionTimeframeService; import com.goldenchart.service.TradeSignalService; import com.goldenchart.trading.pipeline.OrderExecutionQueue; import com.goldenchart.storage.Ta4jStorage; @@ -46,6 +47,7 @@ public class BarBuilder { private final TradeSignalService tradeSignalService; private final OrderExecutionQueue orderExecutionQueue; private final GcLiveStrategySettingsRepository liveSettingsRepo; + private final StrategyConditionTimeframeService conditionTimeframes; /** 상위 집계 타임프레임 (분 단위 집계 주기: 3, 5, 15, 30, 60, 240, 1440) */ private static final int[] UPPER_MINUTES = {3, 5, 15, 30, 60, 240, 1440}; @@ -155,12 +157,12 @@ public class BarBuilder { } } - /** 활성 실시간 전략 설정 중 해당 분봉(candleType)으로 평가하는 항목이 있는지 */ + /** 활성 실시간 전략 설정 중 전략 DSL에 해당 분봉이 포함된 항목이 있는지 */ private boolean shouldEvaluateOnClose(String market, String candleType) { String ct = LiveStrategyTimeframeService.normalize(candleType); return liveSettingsRepo.findActiveByMarket(market).stream() - .anyMatch(s -> ct.equals(LiveStrategyTimeframeService.normalize( - s.getCandleType() != null ? s.getCandleType() : "1m"))); + .filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck()) && s.getStrategyId() != null) + .anyMatch(s -> conditionTimeframes.usesTimeframe(s.getStrategyId(), ct)); } /** diff --git a/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java b/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java index 7043e03..7912fc0 100644 --- a/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java +++ b/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java @@ -4,7 +4,7 @@ import com.goldenchart.dto.CandleBarDto; import com.goldenchart.entity.GcLiveStrategySettings; import com.goldenchart.repository.GcLiveStrategySettingsRepository; import com.goldenchart.service.LiveStrategyEvaluator; -import com.goldenchart.service.LiveStrategyTimeframeService; +import com.goldenchart.service.StrategyConditionTimeframeService; import com.goldenchart.service.TradeSignalService; import com.goldenchart.trading.pipeline.OrderExecutionQueue; import com.goldenchart.storage.Ta4jStorage; @@ -15,7 +15,6 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.util.List; -import java.util.concurrent.ConcurrentHashMap; /** * 3초 주기 라운드 로빈 스케줄러. @@ -39,9 +38,9 @@ public class LiveStrategyScheduler { private final LiveStrategyEvaluator evaluator; private final Ta4jStorage ta4jStorage; private final TradingWebSocketBroker broker; - private final BarBuilder barBuilder; private final TradeSignalService tradeSignalService; private final OrderExecutionQueue orderExecutionQueue; + private final StrategyConditionTimeframeService conditionTimeframes; /** * 3초마다 REALTIME_TICK 설정 종목을 순회하여 전략 판정 후 시그널 발행. @@ -56,14 +55,19 @@ public class LiveStrategyScheduler { if (!"REALTIME_TICK".equals(s.getExecutionType())) continue; if (s.getStrategyId() == null) continue; - String market = s.getMarket(); - String candleType = LiveStrategyTimeframeService.normalize( - s.getCandleType() != null ? s.getCandleType() : "1m"); + String market = s.getMarket(); + for (String candleType : conditionTimeframes.collectForStrategy(s.getStrategyId())) { + evaluateRealtimeTickForMarket(s, market, candleType); + } + } + } - if (!ta4jStorage.exists(market, candleType)) continue; + private void evaluateRealtimeTickForMarket(GcLiveStrategySettings s, String market, + String candleType) { + if (!ta4jStorage.exists(market, candleType)) return; BarSeries series = ta4jStorage.getOrCreate(market, candleType); - if (series.isEmpty()) continue; + if (series.isEmpty()) return; String signal = evaluator.evaluateRealtimeTick(market, candleType); if ("BUY".equals(signal) || "SELL".equals(signal)) { @@ -105,6 +109,5 @@ public class LiveStrategyScheduler { log.warn("[LiveStrategyScheduler] 시그널 DB 저장 실패: {}", ex.getMessage()); } } - } } } diff --git a/backend/src/test/java/com/goldenchart/service/ComplexStrategyDslAdapterTest.java b/backend/src/test/java/com/goldenchart/service/ComplexStrategyDslAdapterTest.java new file mode 100644 index 0000000..15c3bef --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/ComplexStrategyDslAdapterTest.java @@ -0,0 +1,336 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.storage.Ta4jStorage; +import com.goldenchart.trading.pipeline.TradingPipelineProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.ta4j.core.BarSeries; +import org.ta4j.core.BaseBarSeriesBuilder; +import org.ta4j.core.Rule; +import org.ta4j.core.TradingRecord; +import org.ta4j.core.bars.TimeBarBuilderFactory; +import org.ta4j.core.num.DoubleNumFactory; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 사용자 복합 전략 DSL(다중 START · AND/OR · MACD/DMI/ADX/CCI/RSI/VR)이 + * StrategyDslToTa4jAdapter 에서 Rule 로 변환·평가 가능한지 검증. + */ +class ComplexStrategyDslAdapterTest { + + private static final String MARKET = "KRW-BTC"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private StrategyDslToTa4jAdapter adapter; + private Ta4jStorage storage; + private BarSeries primary1m; + + @BeforeEach + void setUp() { + adapter = new StrategyDslToTa4jAdapter(); + TradingPipelineProperties props = new TradingPipelineProperties(); + props.setEnabled(false); + storage = new Ta4jStorage(props); + primary1m = buildSyntheticSeries("1m", 260); + seedStorage(MARKET, "1m", primary1m); + seedStorage(MARKET, "3m", buildSyntheticSeries("3m", 120)); + seedStorage(MARKET, "5m", buildSyntheticSeries("5m", 80)); + } + + /** Logic Expression 예시와 동일 구조의 매수 조건 */ + @Test + void buyCondition_compilesAndEvaluates() throws Exception { + JsonNode buy = MAPPER.readTree(""" + { + "id": "buy-root", + "type": "OR", + "children": [ + { + "id": "buy-and-1", + "type": "AND", + "children": [ + { + "id": "c-macd", + "type": "CONDITION", + "condition": { + "indicatorType": "MACD", + "conditionType": "CROSS_UP", + "leftField": "MACD_LINE", + "rightField": "SIGNAL_LINE", + "candleRange": 1, + "period": 12 + } + }, + { + "id": "c-dmi", + "type": "CONDITION", + "condition": { + "indicatorType": "DMI", + "conditionType": "CROSS_UP", + "leftField": "PDI", + "rightField": "MDI", + "candleRange": 1, + "period": 14 + } + } + ] + }, + { + "id": "buy-and-2", + "type": "AND", + "children": [ + { + "id": "c-adx", + "type": "CONDITION", + "condition": { + "indicatorType": "ADX", + "conditionType": "CROSS_UP", + "leftField": "ADX_VALUE", + "rightField": "K_25", + "candleRange": 1, + "period": 14 + } + }, + { + "id": "c-cci", + "type": "CONDITION", + "condition": { + "indicatorType": "CCI", + "conditionType": "CROSS_UP", + "leftField": "CCI_VALUE", + "rightField": "K_100", + "candleRange": 1, + "period": 13 + } + } + ] + } + ] + } + """); + + Rule rule = adapter.toRule(buy, primary1m, Map.of(), MARKET, storage); + assertNotNull(rule); + assertDoesNotThrow(() -> evaluateRange(rule, primary1m.getEndIndex())); + } + + /** Logic Expression 예시와 동일 구조의 매도 조건 (1m AND 5m AND 3m) */ + @Test + void sellCondition_multiTimeframe_compilesAndEvaluates() throws Exception { + JsonNode sell = MAPPER.readTree(""" + { + "id": "sell-root", + "type": "AND", + "children": [ + { + "id": "tf-1m", + "type": "TIMEFRAME", + "candleType": "1m", + "children": [{ + "id": "sell-1m-and", + "type": "AND", + "children": [ + { + "id": "c-rsi-1m", + "type": "CONDITION", + "condition": { + "indicatorType": "RSI", + "conditionType": "CROSS_DOWN", + "leftField": "RSI_VALUE", + "rightField": "K_70", + "candleRange": 1, + "period": 9 + } + }, + { + "id": "c-macd-1m", + "type": "CONDITION", + "condition": { + "indicatorType": "MACD", + "conditionType": "CROSS_DOWN", + "leftField": "MACD_LINE", + "rightField": "SIGNAL_LINE", + "candleRange": 1, + "period": 12 + } + }, + { + "id": "c-vr-1m", + "type": "CONDITION", + "condition": { + "indicatorType": "VR", + "conditionType": "CROSS_DOWN", + "leftField": "VR_VALUE", + "rightField": "K_200", + "candleRange": 1, + "period": 10 + } + } + ] + }] + }, + { + "id": "tf-5m", + "type": "TIMEFRAME", + "candleType": "5m", + "children": [{ + "id": "sell-5m-and", + "type": "AND", + "children": [ + { + "id": "c-adx-5m", + "type": "CONDITION", + "condition": { + "indicatorType": "ADX", + "conditionType": "CROSS_DOWN", + "leftField": "ADX_VALUE", + "rightField": "K_28", + "candleRange": 1, + "period": 14 + } + }, + { + "id": "c-cci-5m", + "type": "CONDITION", + "condition": { + "indicatorType": "CCI", + "conditionType": "CROSS_DOWN", + "leftField": "CCI_VALUE", + "rightField": "K_26", + "candleRange": 1, + "period": 13 + } + } + ] + }] + }, + { + "id": "tf-3m", + "type": "TIMEFRAME", + "candleType": "3m", + "children": [{ + "id": "sell-3m-and", + "type": "AND", + "children": [ + { + "id": "c-rsi-3m", + "type": "CONDITION", + "condition": { + "indicatorType": "RSI", + "conditionType": "CROSS_DOWN", + "leftField": "RSI_VALUE", + "rightField": "K_20", + "candleRange": 1, + "period": 9 + } + }, + { + "id": "c-vr-3m", + "type": "CONDITION", + "condition": { + "indicatorType": "VR", + "conditionType": "CROSS_DOWN", + "leftField": "VR_VALUE", + "rightField": "K_20", + "candleRange": 1, + "period": 10 + } + } + ] + }] + } + ] + } + """); + + Rule rule = adapter.toRule(sell, primary1m, Map.of(), MARKET, storage); + assertNotNull(rule); + assertDoesNotThrow(() -> evaluateRange(rule, primary1m.getEndIndex())); + + // CrossSeriesRule: 1m 트리거 인덱스에서 5m·3m 시리즈 endIndex 로 평가 + int idx = primary1m.getEndIndex(); + boolean result = rule.isSatisfied(idx, null); + assertTrue(result || !result, "다중 시간봉 AND Rule 평가가 예외 없이 boolean 반환"); + } + + /** 백테스트 경로( storage 미전달 ) — 단일 시리즈 fallback 동작 확인 */ + @Test + void sellCondition_backtestSingleSeries_fallback() throws Exception { + JsonNode sell = MAPPER.readTree(""" + { + "type": "AND", + "children": [ + { "type": "TIMEFRAME", "candleType": "1m", "children": [ + { "type": "CONDITION", "condition": { + "indicatorType": "RSI", "conditionType": "CROSS_DOWN", + "leftField": "RSI_VALUE", "rightField": "K_70", "period": 9 + }} + ]}, + { "type": "TIMEFRAME", "candleType": "5m", "children": [ + { "type": "CONDITION", "condition": { + "indicatorType": "ADX", "conditionType": "CROSS_DOWN", + "leftField": "ADX_VALUE", "rightField": "K_28", "period": 14 + }} + ]} + ] + } + """); + + Rule rule = adapter.toRule(sell, primary1m, Map.of()); + assertNotNull(rule); + assertDoesNotThrow(() -> rule.isSatisfied(primary1m.getEndIndex(), null)); + } + + // ── helpers ──────────────────────────────────────────────────────────────── + + private static void evaluateRange(Rule rule, int endIndex) { + TradingRecord record = null; + int start = Math.max(30, endIndex - 20); + for (int i = start; i <= endIndex; i++) { + rule.isSatisfied(i, record); + } + } + + private static BarSeries buildSyntheticSeries(String timeframe, int barCount) { + Duration period = switch (timeframe) { + case "3m" -> Duration.ofMinutes(3); + case "5m" -> Duration.ofMinutes(5); + default -> Duration.ofMinutes(1); + }; + BarSeries series = new BaseBarSeriesBuilder() + .withName("test-" + timeframe) + .withNumFactory(DoubleNumFactory.getInstance()) + .withBarBuilderFactory(new TimeBarBuilderFactory()) + .build(); + TimeBarBuilderFactory factory = new TimeBarBuilderFactory(); + Instant base = Instant.parse("2024-06-01T00:00:00Z"); + for (int i = 0; i < barCount; i++) { + double wave = Math.sin(i * 0.07) * 8 + Math.cos(i * 0.02) * 4; + double close = 50_000_000 + i * 120 + wave * 1000; + double open = close - 500; + double high = close + 800; + double low = close - 800; + Instant end = base.plus(period.multipliedBy(i + 1L)); + factory.createBarBuilder(series) + .timePeriod(period) + .endTime(end) + .openPrice(open).highPrice(high).lowPrice(low).closePrice(close) + .volume(12.5 + i * 0.01) + .add(); + } + return series; + } + + private void seedStorage(String market, String candleType, BarSeries source) { + for (int i = source.getBeginIndex(); i <= source.getEndIndex(); i++) { + storage.addBar(market, candleType, source.getBar(i)); + } + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3f87e38..7b78075 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import React, { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo } from 'react'; import DrawingToolbar from './components/DrawingToolbar'; import BottomBar from './components/BottomBar'; import TradingChart from './components/TradingChart'; @@ -70,7 +70,7 @@ import { BacktestHistoryPage } from './components/BacktestHistoryPage'; import SettingsPage from './components/SettingsPage'; import PaperTradingPage from './components/PaperTradingPage'; import DashboardPage from './components/DashboardPage'; -import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings } from './utils/backendApi'; +import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi'; import ChartLegendBar from './components/ChartLegendBar'; import RightSidePanel from './components/RightSidePanel'; import { @@ -697,16 +697,14 @@ function App() { // ── 실시간 전략 체크 (전역 설정 + DB 관심종목 = 체크 대상) ───────────────── const [showLivePanel, setShowLivePanel] = useState(false); const [liveStrategies, setLiveStrategies] = useState<{id:number;name:string}[]>([]); - const [liveCandleType, setLiveCandleType] = useState('1m'); + const [liveStrategyCandleTypes, setLiveStrategyCandleTypes] = useState(); useEffect(() => { if (!appSettingsLoaded) return; loadLiveStrategySettings(symbol) - .then(s => { - if (s?.candleType) setLiveCandleType(s.candleType); - }) - .catch(() => { /* 기본 1m 유지 */ }); - }, [symbol, appSettingsLoaded]); + .then(s => setLiveStrategyCandleTypes(s.strategyCandleTypes)) + .catch(() => { /* optional */ }); + }, [symbol, appSettingsLoaded, appDefaults.liveStrategyId]); const liveStrategySettings: LiveStrategySettingsDto = useMemo(() => ({ market: symbol, @@ -714,8 +712,8 @@ function App() { isLiveCheck: appDefaults.liveStrategyCheck ?? false, executionType: appDefaults.liveExecutionType ?? 'CANDLE_CLOSE', positionMode: appDefaults.livePositionMode ?? 'LONG_ONLY', - candleType: liveCandleType, - }), [symbol, appDefaults, liveCandleType]); + strategyCandleTypes: liveStrategyCandleTypes, + }), [symbol, appDefaults, liveStrategyCandleTypes]); /** 실시간 체크 ON + 전략 선택 시 STOMP 구독 대상 (관심종목 + 현재 차트 종목) */ const monitoredMarkets = useMemo(() => { @@ -727,13 +725,12 @@ function App() { const [marketSubscriptions, setMarketSubscriptions] = useState<{ market: string; candleType: string }[]>([]); - /** STOMP 구독 목록 — API 로드 전에도 관심종목 기준으로 바로 구독 (연결 끊김 방지) */ + /** STOMP 구독 목록 — API 로드 전에도 관심종목 × 1m 폴백 구독 */ const liveStompSubscriptions = useMemo(() => { if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return []; if (marketSubscriptions.length > 0) return marketSubscriptions; - const ct = liveCandleType || '1m'; - return monitoredMarkets.map(m => ({ market: m, candleType: ct })); - }, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, marketSubscriptions, monitoredMarkets, liveCandleType]); + return monitoredMarkets.map(m => ({ market: m, candleType: '1m' })); + }, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, marketSubscriptions, monitoredMarkets]); useEffect(() => { if (!appDefaults.liveStrategyCheck) { @@ -741,11 +738,9 @@ function App() { return; } loadActiveLiveStrategySettings() - .then(list => setMarketSubscriptions( - list.map(s => ({ market: s.market, candleType: s.candleType ?? '1m' })), - )) + .then(list => setMarketSubscriptions(expandLiveStrategySubscriptions(list))) .catch(() => { /* liveStompSubscriptions 가 monitoredMarkets 폴백 사용 */ }); - }, [appDefaults.liveStrategyCheck, monitoredMarkets.join(',')]); + }, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, monitoredMarkets.join(',')]); const handleLiveSettingsChange = useCallback((saved: LiveStrategySettingsDto) => { saveAppDef({ @@ -754,12 +749,12 @@ function App() { liveExecutionType: saved.executionType, livePositionMode: saved.positionMode, }); - if (saved.candleType) setLiveCandleType(saved.candleType); + if (saved.strategyCandleTypes) { + setLiveStrategyCandleTypes(saved.strategyCandleTypes); + } if (appDefaults.liveStrategyCheck && saved.isLiveCheck) { loadActiveLiveStrategySettings() - .then(list => setMarketSubscriptions( - list.map(s => ({ market: s.market, candleType: s.candleType ?? '1m' })), - )) + .then(list => setMarketSubscriptions(expandLiveStrategySubscriptions(list))) .catch(() => { /* 폴백 유지 */ }); } }, [saveAppDef, appDefaults.liveStrategyCheck]); @@ -983,8 +978,8 @@ function App() { const barsMarket = useUpbit ? upbitBarsMarket : symbol; barsMarketRef.current = barsMarket; - /** 종목·타임프레임 전환 시 이전 틱 버퍼 폐기 */ - useEffect(() => { + /** 종목·타임프레임 전환 시 이전 틱 버퍼 폐기 (paint 전에 실행) */ + useLayoutEffect(() => { pendingRealtimeBarRef.current = null; }, [symbol, timeframe]); @@ -1981,12 +1976,14 @@ function App() { onManagerReady={mgr => { managerRef.current = mgr; const pending = pendingRealtimeBarRef.current; - if (pending && bars.length > 0) { + if (pending && barsMarketRef.current === symbol && bars.length > 0) { pendingRealtimeBarRef.current = null; const last = bars[bars.length - 1]; if (last && pending.time === last.time) mgr.updateBar(pending); else if (!last || pending.time > last.time) void mgr.appendBar(pending); else mgr.updateBar({ ...pending, time: last.time }); + } else { + pendingRealtimeBarRef.current = null; } mgr.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels); const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null; diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx index 9d0593b..20c864f 100644 --- a/frontend/src/components/ChartSlot.tsx +++ b/frontend/src/components/ChartSlot.tsx @@ -3,7 +3,7 @@ * 레이아웃 내 개별 차트 슬롯. 자체 심볼/타임프레임/데이터를 관리한다. */ import React, { - useState, useEffect, useCallback, useRef, useMemo, + useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo, useImperativeHandle, forwardRef, } from 'react'; import ReactDOM from 'react-dom'; @@ -249,6 +249,7 @@ const ChartSlot = forwardRef(function ChartSlot getTimeframe: () => timeframeRef.current, getIndicators: () => indicatorsRef.current, setSymbol: (s: string) => { + pendingRealtimeBarRef.current = null; // 새 종목의 캐시를 미리 무효화해 신선한 데이터를 요청하도록 함 if (isUpbitMarket(s)) invalidateMarketCache(s); setSymbol(s); @@ -380,7 +381,7 @@ const ChartSlot = forwardRef(function ChartSlot // 데이터 로딩 완료 후 차트가 여전히 비어 있으면 1회만 재마운트 (data + blank guard) const [reloadTick, setReloadTick] = useState(0); const reloadTriggeredRef = useRef(false); // 재마운트는 최대 1회 - useEffect(() => { + useLayoutEffect(() => { reloadTriggeredRef.current = false; pendingRealtimeBarRef.current = null; }, [symbol, timeframe]); @@ -652,12 +653,14 @@ const ChartSlot = forwardRef(function ChartSlot onManagerReady={mgr => { managerRef.current = mgr; const pending = pendingRealtimeBarRef.current; - if (pending) { + if (pending && barsMarketRef.current === symbolRef.current && bars.length > 0) { pendingRealtimeBarRef.current = null; - const last = bars.length > 0 ? bars[bars.length - 1] : null; + const last = bars[bars.length - 1]; if (last && pending.time === last.time) mgr.updateBar(pending); else if (!last || pending.time > last.time) void mgr.appendBar(pending); else mgr.updateBar({ ...pending, time: last.time }); + } else { + pendingRealtimeBarRef.current = null; } mgr.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels); mgr.setVolumeVisible(chartVolumeVisible); diff --git a/frontend/src/components/LiveStrategyPanel.tsx b/frontend/src/components/LiveStrategyPanel.tsx index b765af9..950b80f 100644 --- a/frontend/src/components/LiveStrategyPanel.tsx +++ b/frontend/src/components/LiveStrategyPanel.tsx @@ -14,8 +14,6 @@ import { } from '../utils/backendApi'; import { getKoreanName } from '../utils/marketNameCache'; -const CANDLE_TYPES = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d'] as const; - interface Strategy { id: number; name: string; @@ -94,7 +92,9 @@ const LiveStrategyPanel: React.FC = ({ const monCount = monitoredMarkets.length; const selectedStrategy = strategies.find(s => s.id === stratId); - const candleType = settings.candleType ?? '1m'; + const strategyTimeframes = settings.strategyCandleTypes?.length + ? settings.strategyCandleTypes.join(', ') + : '1m'; const execLabel = execType === 'REALTIME_TICK' ? '실시간 틱 (3초)' : '봉 마감 직후'; const posModeLabel = (settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY' ? '보유 자산 기준' @@ -175,7 +175,7 @@ const LiveStrategyPanel: React.FC = ({
평가 분봉 - {candleType} + {strategyTimeframes}
)} @@ -211,19 +211,6 @@ const LiveStrategyPanel: React.FC = ({ -
- 전략 평가 분봉 - -
실행 방식 @@ -302,7 +289,7 @@ const LiveStrategyPanel: React.FC = ({ {stratId - ? `${selectedStrategy?.name ?? '전략'} · ${candleType} · ${execLabel} · ${posModeLabel} · ${monCount}종목` + ? `${selectedStrategy?.name ?? '전략'} · ${strategyTimeframes} · ${execLabel} · ${posModeLabel} · ${monCount}종목` : '전략을 선택하세요'}
diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index f95bb44..d2b6013 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -300,8 +300,6 @@ interface PaperPanelProps { onLiveAutoTradeBudgetPct?: (v: number) => void; } -const CANDLE_TYPES = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d'] as const; - const PaperPanel: React.FC = ({ paperTradingEnabled = true, onPaperTradingEnabled, @@ -844,20 +842,6 @@ const StrategyPanel: React.FC = ({ - - - - (null); const [selectedNodeId, setSelectedNodeId] = useState(null); const initialDraftBuy = readTabLayout('draft', 'buy'); @@ -780,6 +782,21 @@ export default function StrategyEditorPage({ theme }: Props) { +