From 9d7dddfa57e4c5c1c0b4fa3caaa286ccb008a320 Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 31 May 2026 01:23:47 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A7=A4=EC=88=98,=20=EB=A7=A4=EB=8F=84=20?= =?UTF-8?q?=EC=83=89=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BarCloseStrategyEvaluationService.java | 161 ++++++++++++++++++ .../StrategyConditionTimeframeService.java | 11 +- .../pipeline/CandleGapBackfillService.java | 3 + .../com/goldenchart/websocket/BarBuilder.java | 109 ++---------- ...BarCloseStrategyEvaluationServiceTest.java | 97 +++++++++++ frontend/src/App.css | 80 +++++---- .../src/components/BacktestResultModal.tsx | 2 +- frontend/src/components/TradeAlertModal.tsx | 5 +- .../src/components/VirtualTradingPage.tsx | 5 +- frontend/src/hooks/useVirtualAutoTrade.ts | 117 ------------- .../hooks/useVirtualBackendTradeSignals.ts | 152 +++++++++++++++++ frontend/src/hooks/useVirtualTradingCore.ts | 5 +- frontend/src/styles/backtestDashboard.css | 4 +- frontend/src/styles/dashboardCommand.css | 4 +- frontend/src/styles/paperDashboard.css | 25 +-- frontend/src/styles/strategyEditorTheme.css | 12 +- frontend/src/styles/tradeNotificationList.css | 20 +-- .../src/styles/virtualTradingDashboard.css | 24 +-- frontend/src/utils/ChartManager.ts | 5 +- frontend/src/utils/strategyConditionSerde.ts | 4 + frontend/src/utils/tradeSignalColors.ts | 5 + frontend/src/utils/virtualSignalMetrics.ts | 117 +------------ .../src/utils/virtualStrategyConditions.ts | 20 --- 전략편집기 로직 및 동작흐름.md | 27 ++- 24 files changed, 573 insertions(+), 441 deletions(-) create mode 100644 backend/src/main/java/com/goldenchart/service/BarCloseStrategyEvaluationService.java create mode 100644 backend/src/test/java/com/goldenchart/service/BarCloseStrategyEvaluationServiceTest.java delete mode 100644 frontend/src/hooks/useVirtualAutoTrade.ts create mode 100644 frontend/src/hooks/useVirtualBackendTradeSignals.ts create mode 100644 frontend/src/utils/tradeSignalColors.ts diff --git a/backend/src/main/java/com/goldenchart/service/BarCloseStrategyEvaluationService.java b/backend/src/main/java/com/goldenchart/service/BarCloseStrategyEvaluationService.java new file mode 100644 index 0000000..403be41 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/BarCloseStrategyEvaluationService.java @@ -0,0 +1,161 @@ +package com.goldenchart.service; + +import com.goldenchart.dto.CandleBarDto; +import com.goldenchart.entity.GcLiveStrategySettings; +import com.goldenchart.repository.GcLiveStrategySettingsRepository; +import com.goldenchart.trading.pipeline.OrderExecutionQueue; +import com.goldenchart.websocket.TradingWebSocketBroker; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.ta4j.core.Bar; +import org.ta4j.core.BarSeries; + +import com.goldenchart.storage.Ta4jStorage; + +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 봉 마감 시점 전략 평가 — BarBuilder·갭 백필 등 단일 진입점. + * + *

활성 live 설정 중 전략 DSL에 포함된 분봉({@link StrategyConditionTimeframeService}) + * 에서만 평가하며, 동일 봉 마감(endTime)에 대해 중복 평가하지 않는다. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class BarCloseStrategyEvaluationService { + + private final LiveStrategyEvaluator liveStrategyEvaluator; + private final TradeSignalService tradeSignalService; + private final OrderExecutionQueue orderExecutionQueue; + private final GcLiveStrategySettingsRepository liveSettingsRepo; + private final StrategyConditionTimeframeService conditionTimeframes; + private final TradingWebSocketBroker broker; + private final Ta4jStorage ta4jStorage; + + /** market:candleType:barEndEpoch — 동일 마감봉 중복 평가 방지 */ + private final ConcurrentHashMap evaluatedBarCloseKeys = new ConcurrentHashMap<>(); + + /** + * 확정된 봉(마감)에 대해 전략 평가 + BUY/SELL 시 STOMP·DB·주문 큐. + */ + public void onMaturedBarClose(String market, String candleType, int maturedIndex, Bar signalBar) { + String ct = LiveStrategyTimeframeService.normalize(candleType); + long barEndEpoch = signalBar.getEndTime().getEpochSecond(); + String dedupKey = market + ":" + ct + ":" + barEndEpoch; + if (evaluatedBarCloseKeys.putIfAbsent(dedupKey, barEndEpoch) != null) { + return; + } + trimEvaluatedKeys(); + + List activeSettings = liveSettingsRepo.findActiveByMarket(market).stream() + .filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck()) && s.getStrategyId() != null) + .toList(); + if (activeSettings.isEmpty()) return; + + long candleTimeEpoch = barEndEpoch - signalBar.getTimePeriod().getSeconds(); + + for (GcLiveStrategySettings s : activeSettings) { + if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), ct)) continue; + + String closeSignal = "NONE"; + String signalExecType = null; + + if ("CANDLE_CLOSE".equals(s.getExecutionType())) { + closeSignal = liveStrategyEvaluator.evaluateSettingOnCandleClose( + s, market, ct, maturedIndex); + signalExecType = "CANDLE_CLOSE"; + } else if ("REALTIME_TICK".equals(s.getExecutionType())) { + closeSignal = liveStrategyEvaluator.evaluateSettingRealtimeAtClose( + s, market, ct, maturedIndex); + signalExecType = "REALTIME_TICK"; + } + + if (!"BUY".equals(closeSignal) && !"SELL".equals(closeSignal)) continue; + + publishStrategySignal(market, ct, signalBar, closeSignal, s, signalExecType); + try { + tradeSignalService.save( + s.getDeviceId(), s.getUserId(), + market, s.getStrategyId(), null, + closeSignal, signalBar.getClosePrice().doubleValue(), + candleTimeEpoch, ct, signalExecType); + if (s.getDeviceId() != null) { + orderExecutionQueue.submitSignal( + s.getDeviceId(), s.getUserId(), market, + s.getStrategyId(), closeSignal, + signalBar.getClosePrice().doubleValue()); + } + } catch (Exception e) { + log.warn("[BarCloseEval] 시그널 처리 실패 ({}/{} strategyId={}): {}", + market, ct, s.getStrategyId(), e.getMessage()); + } + } + } + + /** + * REST 갭 백필 등으로 시리즈가 갱신된 뒤, 병합된 확정봉 구간을 순회하며 평가한다. + * + * @param maxMaturedBars 병합된 봉 수(상한) — 중간 마감봉 누락 방지 + */ + public void evaluateRecentMaturedBarsAfterBackfill(String market, String candleType, int maxMaturedBars) { + if (maxMaturedBars <= 0 || !ta4jStorage.exists(market, candleType)) return; + String ct = LiveStrategyTimeframeService.normalize(candleType); + if (!shouldEvaluateOnClose(market, ct)) return; + + BarSeries series = ta4jStorage.getOrCreate(market, candleType); + if (series.isEmpty()) return; + + int end = series.getEndIndex(); + int begin = series.getBeginIndex(); + int maturedEnd = Math.max(begin, end - 1); + int maturedStart = Math.max(begin, maturedEnd - maxMaturedBars + 1); + + for (int i = maturedStart; i <= maturedEnd; i++) { + onMaturedBarClose(market, ct, i, series.getBar(i)); + } + } + + public void invalidateMarket(String market) { + evaluatedBarCloseKeys.keySet().removeIf(k -> k.startsWith(market + ":")); + } + + /** 활성 실시간 전략 중 DSL에 해당 분봉이 포함된 항목이 있는지 */ + public boolean shouldEvaluateOnClose(String market, String candleType) { + String ct = LiveStrategyTimeframeService.normalize(candleType); + return liveSettingsRepo.findActiveByMarket(market).stream() + .filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck()) && s.getStrategyId() != null) + .anyMatch(s -> conditionTimeframes.usesTimeframe(s.getStrategyId(), ct)); + } + + private void publishStrategySignal(String market, String candleType, Bar bar, String signal, + GcLiveStrategySettings setting, String executionType) { + long barStartEpoch = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); + CandleBarDto dto = CandleBarDto.builder() + .time(barStartEpoch) + .open(bar.getOpenPrice().doubleValue()) + .high(bar.getHighPrice().doubleValue()) + .low(bar.getLowPrice().doubleValue()) + .close(bar.getClosePrice().doubleValue()) + .volume(bar.getVolume().doubleValue()) + .signal(signal) + .candleType(candleType) + .strategyId(setting.getStrategyId()) + .userId(setting.getUserId()) + .deviceId(setting.getDeviceId()) + .executionType(executionType) + .build(); + broker.publish(market, candleType, dto); + log.info("[BarCloseEval] bar-close signal={} market={} candleType={} strategyId={} userId={}", + signal, market, candleType, setting.getStrategyId(), setting.getUserId()); + } + + private void trimEvaluatedKeys() { + if (evaluatedBarCloseKeys.size() > 50_000) { + evaluatedBarCloseKeys.clear(); + log.info("[BarCloseEval] evaluatedBarCloseKeys cleared (size cap)"); + } + } +} diff --git a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java index 53cfb9c..ecf573f 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java @@ -67,17 +67,12 @@ public class StrategyConditionTimeframeService { GcStrategy strategy = opt.get(); ensureDslRepaired(strategy); - Set fromFlowLayout = collectFromFlowLayoutJson(strategy.getFlowLayoutJson()); - if (!fromFlowLayout.isEmpty()) { - return sanitizeEvaluationTimeframes(fromFlowLayout, strategy); - } - Set buyOut = new LinkedHashSet<>(); Set sellOut = new LinkedHashSet<>(); boolean buyScoped = collectStartScopeFromJson(strategy.getBuyConditionJson(), buyOut); boolean sellScoped = collectStartScopeFromJson(strategy.getSellConditionJson(), sellOut); - // 매수 START 분봉 우선 — 매도 쪽 레거시 1m TIMEFRAME 이 합집합에 섞이지 않도록 + // 평가 분봉은 buy/sell DSL(TIMEFRAME·START)이 단일 원천 — flow_layout 은 편집기 UI 보조 if (buyScoped && !buyOut.isEmpty()) { return sanitizeEvaluationTimeframes(buyOut, strategy); } @@ -96,6 +91,10 @@ public class StrategyConditionTimeframeService { } if (out.isEmpty()) { + Set fromFlowLayout = collectFromFlowLayoutJson(strategy.getFlowLayoutJson()); + if (!fromFlowLayout.isEmpty()) { + return sanitizeEvaluationTimeframes(fromFlowLayout, strategy); + } String fromName = StrategyDslTimeframeNormalizer.inferFromStrategyName(strategy.getName()); return Set.of(fromName != null ? fromName : "1m"); } diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/CandleGapBackfillService.java b/backend/src/main/java/com/goldenchart/trading/pipeline/CandleGapBackfillService.java index 13d0547..21efc7b 100644 --- a/backend/src/main/java/com/goldenchart/trading/pipeline/CandleGapBackfillService.java +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/CandleGapBackfillService.java @@ -2,6 +2,7 @@ package com.goldenchart.trading.pipeline; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.service.BarCloseStrategyEvaluationService; import com.goldenchart.storage.Ta4jStorage; import com.goldenchart.websocket.UpbitWebSocketClient; import lombok.RequiredArgsConstructor; @@ -37,6 +38,7 @@ import java.util.concurrent.atomic.AtomicLong; public class CandleGapBackfillService { private final Ta4jStorage ta4jStorage; + private final BarCloseStrategyEvaluationService barCloseEvaluation; private final UpbitWebSocketClient upbitWebSocketClient; private final TradingPipelineProperties props; private final WebClient.Builder webClientBuilder; @@ -143,6 +145,7 @@ public class CandleGapBackfillService { BarSeries series = ta4jStorage.getOrCreate(market, candleType); log.debug("[GapBackfill] {} {} — REST {}봉 병합 (series={})", market, candleType, merged, series.getBarCount()); + barCloseEvaluation.evaluateRecentMaturedBarsAfterBackfill(market, candleType, merged); } return merged; } diff --git a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java index ff2dc9a..f07ac07 100644 --- a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java +++ b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java @@ -1,13 +1,7 @@ package com.goldenchart.websocket; 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.service.BarCloseStrategyEvaluationService; import com.goldenchart.storage.Ta4jStorage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -23,7 +17,6 @@ import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.ZoneOffset; -import java.util.List; import java.util.concurrent.ConcurrentHashMap; /** @@ -43,11 +36,7 @@ public class BarBuilder { private final Ta4jStorage ta4jStorage; private final TradingWebSocketBroker broker; - private final LiveStrategyEvaluator liveStrategyEvaluator; - private final TradeSignalService tradeSignalService; - private final OrderExecutionQueue orderExecutionQueue; - private final GcLiveStrategySettingsRepository liveSettingsRepo; - private final StrategyConditionTimeframeService conditionTimeframes; + private final BarCloseStrategyEvaluationService barCloseEvaluation; /** 상위 집계 타임프레임 (분 단위 집계 주기: 3, 5, 10, 15, 30, 60, 240, 1440) */ private static final int[] UPPER_MINUTES = {3, 5, 10, 15, 30, 60, 240, 10080, 1440}; @@ -116,9 +105,9 @@ public class BarBuilder { ta4jStorage.addBar(market, "1m", bar); log.debug("[BarBuilder] 1m Bar 확정: {} @ {}", market, endTime); - if (shouldEvaluateOnClose(market, "1m")) { + if (barCloseEvaluation.shouldEvaluateOnClose(market, "1m")) { BarSeries series1m = ta4jStorage.getOrCreate(market, "1m"); - evaluateStrategyOnBarClose(market, "1m", series1m.getEndIndex(), bar); + barCloseEvaluation.onMaturedBarClose(market, "1m", series1m.getEndIndex(), bar); } // 상위 타임프레임 전파 @@ -147,98 +136,20 @@ public class BarBuilder { Bar upperBar = buildTa4jBar(partial, Duration.ofMinutes(minutes), upperEnd); ta4jStorage.addBar(market, type, upperBar); BarSeries upperAfter = ta4jStorage.getOrCreate(market, type); - if (shouldEvaluateOnClose(market, type)) { + if (barCloseEvaluation.shouldEvaluateOnClose(market, type)) { int maturedUpper = Math.max(upperAfter.getBeginIndex(), upperAfter.getEndIndex() - 1); Bar maturedUpperBar = upperAfter.getBar(maturedUpper); - evaluateStrategyOnBarClose(market, type, maturedUpper, maturedUpperBar); + barCloseEvaluation.onMaturedBarClose(market, type, maturedUpper, maturedUpperBar); } + } else { + log.warn("[BarBuilder] 상위봉 endTime 역행 {} {} last={} expected={} — 갱신만 수행", + market, type, lastUpperEndInstant, upperEndInstant); + ta4jStorage.updateLastBar(market, type, partial.close, partial.volume); } } } } - /** 활성 실시간 전략 설정 중 전략 DSL에 해당 분봉이 포함된 항목이 있는지 */ - private boolean shouldEvaluateOnClose(String market, String candleType) { - String ct = LiveStrategyTimeframeService.normalize(candleType); - return liveSettingsRepo.findActiveByMarket(market).stream() - .filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck()) && s.getStrategyId() != null) - .anyMatch(s -> conditionTimeframes.usesTimeframe(s.getStrategyId(), ct)); - } - - /** - * 봉 마감 시점 전략 평가 + 시그널 STOMP 발행(평가 분봉 채널) + DB/주문 큐 적재. - */ - private void evaluateStrategyOnBarClose(String market, String candleType, int maturedIndex, - Bar signalBar) { - List activeSettings = liveSettingsRepo.findActiveByMarket(market).stream() - .filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck()) && s.getStrategyId() != null) - .toList(); - if (activeSettings.isEmpty()) return; - - long candleTimeEpoch = signalBar.getEndTime().getEpochSecond() - - signalBar.getTimePeriod().getSeconds(); - - for (GcLiveStrategySettings s : activeSettings) { - if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) continue; - - String closeSignal = "NONE"; - String signalExecType = null; - - if ("CANDLE_CLOSE".equals(s.getExecutionType())) { - closeSignal = liveStrategyEvaluator.evaluateSettingOnCandleClose( - s, market, candleType, maturedIndex); - signalExecType = "CANDLE_CLOSE"; - } else if ("REALTIME_TICK".equals(s.getExecutionType())) { - closeSignal = liveStrategyEvaluator.evaluateSettingRealtimeAtClose( - s, market, candleType, maturedIndex); - signalExecType = "REALTIME_TICK"; - } - - if (!"BUY".equals(closeSignal) && !"SELL".equals(closeSignal)) continue; - - publishStrategySignal(market, candleType, signalBar, closeSignal, s, signalExecType); - try { - tradeSignalService.save( - s.getDeviceId(), s.getUserId(), - market, s.getStrategyId(), null, - closeSignal, signalBar.getClosePrice().doubleValue(), - candleTimeEpoch, candleType, signalExecType); - if (s.getDeviceId() != null) { - orderExecutionQueue.submitSignal( - s.getDeviceId(), s.getUserId(), market, - s.getStrategyId(), closeSignal, - signalBar.getClosePrice().doubleValue()); - } - } catch (Exception e) { - log.warn("[BarBuilder] 시그널 처리 실패 ({}/{} strategyId={}): {}", - market, candleType, s.getStrategyId(), e.getMessage()); - } - } - } - - /** 평가 분봉 STOMP 채널로 봉 마감 시그널 발행 (소유자·전략 메타 포함) */ - private void publishStrategySignal(String market, String candleType, Bar bar, String signal, - GcLiveStrategySettings setting, String executionType) { - long barStartEpoch = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); - CandleBarDto dto = CandleBarDto.builder() - .time(barStartEpoch) - .open(bar.getOpenPrice().doubleValue()) - .high(bar.getHighPrice().doubleValue()) - .low(bar.getLowPrice().doubleValue()) - .close(bar.getClosePrice().doubleValue()) - .volume(bar.getVolume().doubleValue()) - .signal(signal) - .candleType(candleType) - .strategyId(setting.getStrategyId()) - .userId(setting.getUserId()) - .deviceId(setting.getDeviceId()) - .executionType(executionType) - .build(); - broker.publish(market, candleType, dto); - log.info("[BarBuilder] bar-close signal={} market={} candleType={} strategyId={} userId={}", - signal, market, candleType, setting.getStrategyId(), setting.getUserId()); - } - /** 현재 진행 중인 1분봉을 STOMP 로 발행 (실시간 틱 렌더링용, 시그널 없음). */ private void publishCurrentBar(String market) { PartialBar p = partialBars.get(market); diff --git a/backend/src/test/java/com/goldenchart/service/BarCloseStrategyEvaluationServiceTest.java b/backend/src/test/java/com/goldenchart/service/BarCloseStrategyEvaluationServiceTest.java new file mode 100644 index 0000000..16d04a2 --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/BarCloseStrategyEvaluationServiceTest.java @@ -0,0 +1,97 @@ +package com.goldenchart.service; + +import com.goldenchart.entity.GcLiveStrategySettings; +import com.goldenchart.repository.GcLiveStrategySettingsRepository; +import com.goldenchart.storage.Ta4jStorage; +import com.goldenchart.trading.pipeline.OrderExecutionQueue; +import com.goldenchart.websocket.TradingWebSocketBroker; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.ta4j.core.Bar; +import org.ta4j.core.BarSeries; +import org.ta4j.core.BaseBarSeriesBuilder; +import org.ta4j.core.bars.TimeBarBuilderFactory; +import org.ta4j.core.num.DoubleNumFactory; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class BarCloseStrategyEvaluationServiceTest { + + @Mock private LiveStrategyEvaluator liveStrategyEvaluator; + @Mock private TradeSignalService tradeSignalService; + @Mock private OrderExecutionQueue orderExecutionQueue; + @Mock private GcLiveStrategySettingsRepository liveSettingsRepo; + @Mock private StrategyConditionTimeframeService conditionTimeframes; + @Mock private TradingWebSocketBroker broker; + @Mock private Ta4jStorage ta4jStorage; + + private BarCloseStrategyEvaluationService service; + + @BeforeEach + void setUp() { + service = new BarCloseStrategyEvaluationService( + liveStrategyEvaluator, tradeSignalService, orderExecutionQueue, + liveSettingsRepo, conditionTimeframes, broker, ta4jStorage); + } + + @Test + void onMaturedBarClose_evaluatesOnlyDslTimeframes_andDedupesSameBarEnd() { + GcLiveStrategySettings setting = new GcLiveStrategySettings(); + setting.setStrategyId(10L); + setting.setIsLiveCheck(true); + setting.setExecutionType("CANDLE_CLOSE"); + setting.setMarket("KRW-BTC"); + when(liveSettingsRepo.findActiveByMarket("KRW-BTC")).thenReturn(List.of(setting)); + when(conditionTimeframes.usesTimeframe(10L, "5m")).thenReturn(true); + when(conditionTimeframes.usesTimeframe(10L, "15m")).thenReturn(false); + when(liveStrategyEvaluator.evaluateSettingOnCandleClose(eq(setting), eq("KRW-BTC"), eq("5m"), anyInt())) + .thenReturn("NONE"); + + Bar bar = sampleBar(Duration.ofMinutes(5), Instant.parse("2026-05-29T10:05:00Z")); + service.onMaturedBarClose("KRW-BTC", "5m", 0, bar); + service.onMaturedBarClose("KRW-BTC", "5m", 0, bar); + + verify(liveStrategyEvaluator, times(1)) + .evaluateSettingOnCandleClose(setting, "KRW-BTC", "5m", 0); + verify(broker, never()).publish(anyString(), anyString(), any()); + } + + @Test + void shouldEvaluateOnClose_falseWhenNoMatchingTimeframe() { + when(liveSettingsRepo.findActiveByMarket("KRW-ETH")).thenReturn(List.of()); + assertFalse(service.shouldEvaluateOnClose("KRW-ETH", "3m")); + } + + @Test + void shouldEvaluateOnClose_trueWhenStrategyUsesTimeframe() { + GcLiveStrategySettings s = new GcLiveStrategySettings(); + s.setStrategyId(1L); + s.setIsLiveCheck(true); + when(liveSettingsRepo.findActiveByMarket("KRW-BTC")).thenReturn(List.of(s)); + when(conditionTimeframes.usesTimeframe(1L, "10m")).thenReturn(true); + assertTrue(service.shouldEvaluateOnClose("KRW-BTC", "10m")); + } + + private static Bar sampleBar(Duration period, Instant end) { + return new BaseBarSeriesBuilder() + .withBarBuilderFactory(new TimeBarBuilderFactory()) + .withNumFactory(DoubleNumFactory.getInstance()) + .build() + .barBuilder() + .timePeriod(period) + .endTime(end) + .openPrice(1).highPrice(2).lowPrice(0.5).closePrice(1.5).volume(10) + .build(); + } +} diff --git a/frontend/src/App.css b/frontend/src/App.css index 5ef595e..5009cae 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -24,6 +24,11 @@ --accent-h: #5a7dc7; --up: #ff6b6b; --down: #4dabf7; + /* 매매 시그널 — 매수(빨강) · 매도(파랑). 시세 등락(--up/--down)과 동일 톤 */ + --gc-trade-buy: #ef5350; + --gc-trade-sell: #4dabf7; + --gc-trade-buy-hover: #e53935; + --gc-trade-sell-hover: #339af0; --panel-bg: #24283bee; --sep: rgba(122, 162, 247, 0.15); --panel: #24283b; @@ -6648,8 +6653,8 @@ html.theme-blue { border-radius: 50%; flex-shrink: 0; } -.strat-cond-dot--buy { background: var(--up); } -.strat-cond-dot--sell { background: var(--down); } +.strat-cond-dot--buy { background: var(--gc-trade-buy); } +.strat-cond-dot--sell { background: var(--gc-trade-sell); } .strat-cond-empty { font-size: 12px; color: var(--text3); @@ -7024,8 +7029,8 @@ html.theme-blue { display: inline-block; font-size: 12px; padding: 3px 10px; border-radius: 99px; margin-bottom: 8px; } -.sp-prev-badge--buy { background: rgba(72,199,116,.2); color: #48c774; } -.sp-prev-badge--sell { background: rgba(255,107,107,.2); color: #ff6b6b; } +.sp-prev-badge--buy { background: color-mix(in srgb, var(--gc-trade-buy) 22%, transparent); color: var(--gc-trade-buy); } +.sp-prev-badge--sell { background: color-mix(in srgb, var(--gc-trade-sell) 22%, transparent); color: var(--gc-trade-sell); } .sp-prev-text { background: var(--bg3); border: 1px solid var(--border); padding: 12px; font-size: 13px; line-height: 1.6; @@ -7036,8 +7041,8 @@ html.theme-blue { border-bottom: 2px solid var(--border); padding-bottom: 4px; margin-bottom: 10px; } .sp-prev-formula { font-size: 13px; font-family: monospace; line-height: 1.6; margin-bottom: 6px; } -.sp-prev-buy { color: #48c774; font-weight: 700; } -.sp-prev-sell { color: #ff6b6b; font-weight: 700; } +.sp-prev-buy { color: var(--gc-trade-buy); font-weight: 700; } +.sp-prev-sell { color: var(--gc-trade-sell); font-weight: 700; } .sp-validation { margin-bottom: 8px; } .sp-val-head { font-size: 12px; font-weight: 600; margin-bottom: 4px; } .sp-val-ok { color: #48c774; font-size: 13px; padding: 5px 10px; background: rgba(72,199,116,.1); border-radius: var(--radius); } @@ -7096,11 +7101,11 @@ html.theme-blue { color: var(--text2); cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 4px; } -.sp-signal-tab--buy { color: #ff6b6b; border-bottom-color: #ff6b6b; font-weight: 600; background: rgba(255,107,107,.06); } -.sp-signal-tab--sell { color: #4dabf7; border-bottom-color: #4dabf7; font-weight: 600; background: rgba(77,171,247,.06); } +.sp-signal-tab--buy { color: var(--gc-trade-buy); border-bottom-color: var(--gc-trade-buy); font-weight: 600; background: color-mix(in srgb, var(--gc-trade-buy) 8%, transparent); } +.sp-signal-tab--sell { color: var(--gc-trade-sell); border-bottom-color: var(--gc-trade-sell); font-weight: 600; background: color-mix(in srgb, var(--gc-trade-sell) 8%, transparent); } .sp-signal-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } -.sp-signal-dot--buy { background: #48c774; } -.sp-signal-dot--sell { background: #ff6b6b; } +.sp-signal-dot--buy { background: var(--gc-trade-buy); } +.sp-signal-dot--sell { background: var(--gc-trade-sell); } /* 드롭존 */ .sp-dropzone { @@ -7383,8 +7388,8 @@ html.theme-blue { .sp-tmpl-badge { font-size: 11px; padding: 3px 8px; border-radius: 99px; white-space: nowrap; font-weight: 600; } -.sp-tmpl-badge--buy { background: rgba(72,199,116,.2); color: #48c774; } -.sp-tmpl-badge--sell { background: rgba(255,107,107,.2); color: #ff6b6b; } +.sp-tmpl-badge--buy { background: color-mix(in srgb, var(--gc-trade-buy) 22%, transparent); color: var(--gc-trade-buy); } +.sp-tmpl-badge--sell { background: color-mix(in srgb, var(--gc-trade-sell) 22%, transparent); color: var(--gc-trade-sell); } .sp-tmpl-name { flex: 1 1 auto; color: var(--text); } .sp-tmpl-desc { flex: 1 1 100%; @@ -7444,8 +7449,8 @@ html.theme-blue { font-size: 11px; padding: 2px 8px; border-radius: 99px; border: 1px solid var(--border); color: var(--text2); } -.sp-modal-chip--buy { border-color: #48c774; color: #48c774; } -.sp-modal-chip--sell { border-color: #ff6b6b; color: #ff6b6b; } +.sp-modal-chip--buy { border-color: var(--gc-trade-buy); color: var(--gc-trade-buy); } +.sp-modal-chip--sell { border-color: var(--gc-trade-sell); color: var(--gc-trade-sell); } .sp-modal-lbl { display: block; font-size: 12px; color: var(--text2); margin: 10px 0 4px; } .sp-modal-inp { width: 100%; padding: 8px 10px; font-size: 13px; @@ -8717,6 +8722,8 @@ html.theme-blue { .brd-sig-badge.brd-pos { background: rgba(38,201,126,.12); } .brd-sig-badge.brd-neg { background: rgba(240,78,94,.12); } .brd-sig-badge.brd-warn{ background: rgba(255,159,67,.12); } +.brd-sig-badge.brd-sig-buy { background: color-mix(in srgb, var(--gc-trade-buy) 22%, transparent); color: var(--gc-trade-buy); } +.brd-sig-badge.brd-sig-sell { background: color-mix(in srgb, var(--gc-trade-sell) 22%, transparent); color: var(--gc-trade-sell); } .brd-expand-btn { display: block; width: 100%; @@ -10024,12 +10031,12 @@ html.theme-blue { box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18); } .rsp-trade-card--buy { - border-color: rgba(255, 107, 107, 0.35); - background: linear-gradient(180deg, rgba(255, 107, 107, 0.08) 0%, var(--bg3) 48px); + border-color: color-mix(in srgb, var(--gc-trade-buy) 40%, transparent); + background: linear-gradient(180deg, color-mix(in srgb, var(--gc-trade-buy) 10%, transparent) 0%, var(--bg3) 48px); } .rsp-trade-card--sell { - border-color: rgba(77, 171, 247, 0.35); - background: linear-gradient(180deg, rgba(77, 171, 247, 0.08) 0%, var(--bg3) 48px); + border-color: color-mix(in srgb, var(--gc-trade-sell) 40%, transparent); + background: linear-gradient(180deg, color-mix(in srgb, var(--gc-trade-sell) 10%, transparent) 0%, var(--bg3) 48px); } .rsp-trade-card-title { padding: 7px 12px 5px; @@ -10038,10 +10045,10 @@ html.theme-blue { border-bottom: 1px solid var(--border); } .rsp-trade-card--buy .rsp-trade-card-title { - color: var(--up); + color: var(--gc-trade-buy); } .rsp-trade-card--sell .rsp-trade-card-title { - color: var(--down); + color: var(--gc-trade-sell); } .rsp-trade-card-body .top-panel { flex: 1 1 auto; @@ -10547,16 +10554,17 @@ html.theme-blue { cursor: pointer; } .top-submit--buy { - background: #ef5350; + background: var(--gc-trade-buy); + color: #fff; } .top-submit--buy:hover { - background: #e53935; + background: var(--gc-trade-buy-hover); } .top-submit--sell { - background: #2962ff; + background: var(--gc-trade-sell); } .top-submit--sell:hover { - background: #1e53e5; + background: var(--gc-trade-sell-hover); } .app.light .top-kind-btn--active { border-color: #1976d2; @@ -10658,10 +10666,10 @@ html.theme-blue { background: var(--bg3); } .chart-ctx-menu-item--buy { - color: #ef5350; + color: var(--gc-trade-buy); } .chart-ctx-menu-item--sell { - color: #2962ff; + color: var(--gc-trade-sell); } .app.light .chart-ctx-menu { background: #fff; @@ -11489,8 +11497,8 @@ html.theme-light .tam-disclaimer { color: #90a4ae; } .tsn-card--latest { box-shadow: 0 14px 44px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(77, 171, 247, 0.12); } -.tsn-card--buy { --tsn-accent: #3f7ef5; } -.tsn-card--sell { --tsn-accent: #ef5350; } +.tsn-card--buy { --tsn-accent: var(--gc-trade-buy); } +.tsn-card--sell { --tsn-accent: var(--gc-trade-sell); } .tsn-card-header { cursor: grab; @@ -11512,8 +11520,8 @@ html.theme-light .tam-disclaimer { color: #90a4ae; } border-radius: 3px; letter-spacing: 0.04em; } -.tsn-badge--buy { background: rgba(63,126,245,0.2); color: #7aa2f7; } -.tsn-badge--sell { background: rgba(239,83,80,0.2); color: #f7768e; } +.tsn-badge--buy { background: color-mix(in srgb, var(--gc-trade-buy) 25%, transparent); color: var(--gc-trade-buy); } +.tsn-badge--sell { background: color-mix(in srgb, var(--gc-trade-sell) 25%, transparent); color: var(--gc-trade-sell); } .tsn-card-title { flex: 1; font-size: 13px; @@ -11551,8 +11559,8 @@ html.theme-light .tam-disclaimer { color: #90a4ae; } font-size: 15px; font-weight: 700; } -.tsd-direction--buy { color: var(--up, #ff6b6b); } -.tsd-direction--sell { color: var(--down, #4dabf7); } +.tsd-direction--buy { color: var(--gc-trade-buy); } +.tsd-direction--sell { color: var(--gc-trade-sell); } .tsd-headline-price { font-size: 18px; font-weight: 700; @@ -11935,8 +11943,8 @@ html.theme-light .tam-disclaimer { color: #90a4ae; } border-radius: 4px; flex-shrink: 0; } -.tnl-signal--buy { background: rgba(63,126,245,0.2); color: #7aa2f7; } -.tnl-signal--sell { background: rgba(239,83,80,0.2); color: #f7768e; } +.tnl-signal--buy { background: color-mix(in srgb, var(--gc-trade-buy) 25%, transparent); color: var(--gc-trade-buy); } +.tnl-signal--sell { background: color-mix(in srgb, var(--gc-trade-sell) 25%, transparent); color: var(--gc-trade-sell); } .tnl-row-body { display: flex; flex-direction: column; gap: 4px; min-width: 0; } .tnl-row-title { font-size: 14px; font-weight: 600; } .tnl-row-price { @@ -12406,8 +12414,8 @@ html.theme-light .tam-disclaimer { color: #90a4ae; } .dashboard-kv--cols li { border: none; flex-direction: column; gap: 4px; } .dashboard-table { width: 100%; border-collapse: collapse; font-size: 13px; } .dashboard-table th, .dashboard-table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--border); } -.dashboard-table .buy { color: #26a69a; } -.dashboard-table .sell { color: #ef5350; } +.dashboard-table .buy { color: var(--gc-trade-buy); } +.dashboard-table .sell { color: var(--gc-trade-sell); } .dashboard-link { background: none; border: none; color: var(--accent); cursor: pointer; padding: 0; } .dashboard-muted { color: var(--text2); font-size: 13px; } .dashboard-error { color: #ef5350; font-size: 13px; } diff --git a/frontend/src/components/BacktestResultModal.tsx b/frontend/src/components/BacktestResultModal.tsx index d83a1c6..1bc9762 100644 --- a/frontend/src/components/BacktestResultModal.tsx +++ b/frontend/src/components/BacktestResultModal.tsx @@ -168,7 +168,7 @@ const SIG_LABEL: Record = { BUY:'매수', SELL:'매도', SHORT_ENTRY:'공매도', SHORT_EXIT:'공매도청산', PARTIAL_SELL:'분할매도', }; const SIG_COLOR: Record = { - BUY:'brd-pos', SELL:'brd-neg', SHORT_ENTRY:'brd-neg', SHORT_EXIT:'brd-pos', PARTIAL_SELL:'brd-warn', + BUY:'brd-sig-buy', SELL:'brd-sig-sell', SHORT_ENTRY:'brd-neg', SHORT_EXIT:'brd-pos', PARTIAL_SELL:'brd-warn', }; // ══════════════════════════════════════════════════════════════════════════ diff --git a/frontend/src/components/TradeAlertModal.tsx b/frontend/src/components/TradeAlertModal.tsx index 6dd38fb..6ae422d 100644 --- a/frontend/src/components/TradeAlertModal.tsx +++ b/frontend/src/components/TradeAlertModal.tsx @@ -3,6 +3,7 @@ import React, { useState } from 'react'; import { useDraggablePanel } from '../hooks/useDraggablePanel'; import { placePaperOrder, placeLiveOrder, loadLiveSummary } from '../utils/backendApi'; import { formatSignalTime } from '../utils/tradeSignalDisplay'; +import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from '../utils/tradeSignalColors'; import { useTradeAlertTimeFormat } from '../utils/tradeAlertTimeFormat'; export interface TradeSignalInfo { @@ -61,8 +62,8 @@ export const TradeAlertModal: React.FC = ({ }) => { useTradeAlertTimeFormat(); const isBuy = signal.signalType === 'BUY'; - const accentColor = isBuy ? '#3f7ef5' : '#ef5350'; - const accentGlow = isBuy ? 'rgba(63,126,245,0.22)' : 'rgba(239,83,80,0.22)'; + const accentColor = isBuy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR; + const accentGlow = isBuy ? 'rgba(239, 83, 80, 0.28)' : 'rgba(77, 171, 247, 0.28)'; const signalLabel = isBuy ? 'BUY' : 'SELL'; const signalKo = isBuy ? '매수 알림' : '매도 알림'; diff --git a/frontend/src/components/VirtualTradingPage.tsx b/frontend/src/components/VirtualTradingPage.tsx index 13d634a..179a22b 100644 --- a/frontend/src/components/VirtualTradingPage.tsx +++ b/frontend/src/components/VirtualTradingPage.tsx @@ -30,7 +30,7 @@ import { } from './virtual/VirtualGridUnifiedHeader'; import type { VirtualCardDisplayMode } from './virtual/VirtualTargetCard'; import { useVirtualIndicatorSnapshots } from '../hooks/useVirtualIndicatorSnapshots'; -import { useVirtualAutoTrade } from '../hooks/useVirtualAutoTrade'; +import { useVirtualBackendTradeSignals } from '../hooks/useVirtualBackendTradeSignals'; import { useVirtualTargetLiveStatus } from '../hooks/useVirtualTargetLiveStatus'; import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData'; import { @@ -197,11 +197,10 @@ const VirtualTradingPage: React.FC = ({ session.running, ); - useVirtualAutoTrade({ + useVirtualBackendTradeSignals({ enabled: paperTradingEnabled && paperAutoTradeEnabled, session, targets, - snapshots, tickers, paperAutoTradeBudgetPct, positions: summary?.positions, diff --git a/frontend/src/hooks/useVirtualAutoTrade.ts b/frontend/src/hooks/useVirtualAutoTrade.ts deleted file mode 100644 index ddc177c..0000000 --- a/frontend/src/hooks/useVirtualAutoTrade.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * 가상투자 Match(전략 조건 100% 충족) 시 자동 모의 체결 - * 설정 · 가상투자 · 자동매매 ON + 세션 running 일 때만 동작 - */ -import { useEffect, useRef } from 'react'; -import type { TickerData } from './useMarketTicker'; -import type { VirtualIndicatorSnapshot } from './useVirtualIndicatorSnapshots'; -import type { VirtualSessionConfig, VirtualTargetItem } from '../utils/virtualTradingStorage'; -import { placePaperOrder, type PaperSummaryDto } from '../utils/backendApi'; -import { - buildConditionMetrics, - resolveVirtualTradeTiming, - type VirtualTradeTiming, -} from '../utils/virtualSignalMetrics'; -import { coerceFiniteNumber } from '../utils/safeFormat'; - -const EXEC_COOLDOWN_MS = 15_000; - -interface Options { - enabled: boolean; - session: VirtualSessionConfig; - targets: VirtualTargetItem[]; - snapshots: Record; - tickers?: Map; - paperAutoTradeBudgetPct: number; - positions?: PaperSummaryDto['positions']; - onFilled?: () => void; -} - -export function useVirtualAutoTrade({ - enabled, - session, - targets, - snapshots, - tickers, - paperAutoTradeBudgetPct, - positions = [], - onFilled, -}: Options): void { - const prevTimingRef = useRef>({}); - const inflightRef = useRef>(new Set()); - const lastExecRef = useRef>({}); - - useEffect(() => { - if (!enabled || !session.running) { - prevTimingRef.current = {}; - return; - } - - for (const target of targets) { - const snap = snapshots[target.market]; - if (!snap?.rows?.length) continue; - - const metrics = buildConditionMetrics(snap.rows); - const timing = resolveVirtualTradeTiming(metrics); - const prev = prevTimingRef.current[target.market] ?? 'neutral'; - prevTimingRef.current[target.market] = timing; - - if (timing !== 'buy' && timing !== 'sell') continue; - if (timing === prev) continue; - - const price = coerceFiniteNumber(tickers?.get(target.market)?.tradePrice); - if (price == null || price <= 0) continue; - - const heldQty = coerceFiniteNumber( - positions.find(p => p.symbol === target.market)?.quantity, - ) ?? 0; - - if (session.positionMode === 'LONG_ONLY') { - if (timing === 'buy' && heldQty > 0) continue; - if (timing === 'sell' && heldQty <= 0) continue; - } - - const side = timing === 'buy' ? 'BUY' : 'SELL'; - const execKey = `${target.market}:${side}`; - if (inflightRef.current.has(execKey)) continue; - if (Date.now() - (lastExecRef.current[execKey] ?? 0) < EXEC_COOLDOWN_MS) continue; - - const strategyId = target.strategyId ?? session.globalStrategyId ?? null; - inflightRef.current.add(execKey); - - void placePaperOrder({ - market: target.market, - side, - orderKind: 'market', - price, - quantity: 0, - budgetPct: timing === 'buy' ? paperAutoTradeBudgetPct : 100, - source: 'STRATEGY', - strategyId, - }) - .then(trade => { - if (trade) { - lastExecRef.current[execKey] = Date.now(); - onFilled?.(); - } - }) - .catch(err => { - console.warn('[VirtualAutoTrade]', target.market, side, err); - }) - .finally(() => { - inflightRef.current.delete(execKey); - }); - } - }, [ - enabled, - session.running, - session.globalStrategyId, - session.positionMode, - targets, - snapshots, - tickers, - paperAutoTradeBudgetPct, - positions, - onFilled, - ]); -} diff --git a/frontend/src/hooks/useVirtualBackendTradeSignals.ts b/frontend/src/hooks/useVirtualBackendTradeSignals.ts new file mode 100644 index 0000000..b8ed8cb --- /dev/null +++ b/frontend/src/hooks/useVirtualBackendTradeSignals.ts @@ -0,0 +1,152 @@ +/** + * 가상투자 자동매매 — 백엔드 BarBuilder/LiveStrategyEvaluator 가 발행한 STOMP BUY/SELL 만 체결. + * 프론트 근사 일치율(resolveVirtualTradeTiming) 사용 안 함. + */ +import { useEffect, useRef } from 'react'; +import type { TickerData } from './useMarketTicker'; +import type { VirtualSessionConfig, VirtualTargetItem } from '../utils/virtualTradingStorage'; +import { loadStrategyTimeframes, placePaperOrder, type PaperSummaryDto } from '../utils/backendApi'; +import { parseStompJson } from '../utils/stompMessage'; +import { subscribeStompTopic } from '../utils/stompChartBroker'; +import { signalBelongsToCurrentSession } from '../utils/tradeSignalOwnership'; +import { resolveVirtualTargetStrategyId } from '../utils/virtualTargetStrategy'; +import { coerceFiniteNumber } from '../utils/safeFormat'; + +const EXEC_COOLDOWN_MS = 15_000; + +interface Options { + enabled: boolean; + session: VirtualSessionConfig; + targets: VirtualTargetItem[]; + tickers?: Map; + paperAutoTradeBudgetPct: number; + positions?: PaperSummaryDto['positions']; + onFilled?: () => void; +} + +export function useVirtualBackendTradeSignals({ + enabled, + session, + targets, + tickers, + paperAutoTradeBudgetPct, + positions = [], + onFilled, +}: Options): void { + const inflightRef = useRef>(new Set()); + const lastExecRef = useRef>({}); + const seenSignalRef = useRef>(new Set()); + + useEffect(() => { + if (!enabled || !session.running) { + seenSignalRef.current.clear(); + return; + } + + const activeTargets = targets.filter( + t => resolveVirtualTargetStrategyId(t, session.globalStrategyId) != null, + ); + if (activeTargets.length === 0) return; + + const unsubs: Array<() => void> = []; + let cancelled = false; + + void (async () => { + for (const target of activeTargets) { + if (cancelled) break; + const strategyId = resolveVirtualTargetStrategyId(target, session.globalStrategyId)!; + let timeframes: string[] = []; + try { + timeframes = await loadStrategyTimeframes(strategyId); + } catch { + timeframes = ['1m']; + } + if (timeframes.length === 0) timeframes = ['1m']; + + for (const candleType of timeframes) { + const topic = `/sub/charts/${target.market}/${candleType}`; + const unsub = subscribeStompTopic(topic, msg => { + const data = parseStompJson<{ + time: number; + close: number; + signal?: string; + candleType?: string; + strategyId?: number | null; + userId?: number | null; + deviceId?: string | null; + }>(msg); + if (!data?.signal || (data.signal !== 'BUY' && data.signal !== 'SELL')) return; + if (data.strategyId != null && data.strategyId !== strategyId) return; + if (!signalBelongsToCurrentSession({ userId: data.userId, deviceId: data.deviceId })) { + return; + } + + const dedup = `${target.market}:${data.candleType ?? candleType}:${data.time}:${data.signal}:${strategyId}`; + if (seenSignalRef.current.has(dedup)) return; + seenSignalRef.current.add(dedup); + if (seenSignalRef.current.size > 5000) { + seenSignalRef.current.clear(); + } + + const side = data.signal as 'BUY' | 'SELL'; + const price = coerceFiniteNumber(tickers?.get(target.market)?.tradePrice) + ?? coerceFiniteNumber(data.close); + if (price == null || price <= 0) return; + + const heldQty = coerceFiniteNumber( + positions.find(p => p.symbol === target.market)?.quantity, + ) ?? 0; + if (session.positionMode === 'LONG_ONLY') { + if (side === 'BUY' && heldQty > 0) return; + if (side === 'SELL' && heldQty <= 0) return; + } + + const execKey = `${target.market}:${side}`; + if (inflightRef.current.has(execKey)) return; + if (Date.now() - (lastExecRef.current[execKey] ?? 0) < EXEC_COOLDOWN_MS) return; + + inflightRef.current.add(execKey); + void placePaperOrder({ + market: target.market, + side, + orderKind: 'market', + price, + quantity: 0, + budgetPct: side === 'BUY' ? paperAutoTradeBudgetPct : 100, + source: 'STRATEGY', + strategyId, + }) + .then(trade => { + if (trade) { + lastExecRef.current[execKey] = Date.now(); + onFilled?.(); + } + }) + .catch(err => { + console.warn('[VirtualBackendTrade]', target.market, side, err); + }) + .finally(() => { + inflightRef.current.delete(execKey); + }); + }); + unsubs.push(unsub); + } + } + })(); + + return () => { + cancelled = true; + unsubs.forEach(u => u()); + }; + }, [ + enabled, + session.running, + session.globalStrategyId, + session.positionMode, + targets, + tickers, + paperAutoTradeBudgetPct, + positions, + onFilled, + ]); +} diff --git a/frontend/src/hooks/useVirtualTradingCore.ts b/frontend/src/hooks/useVirtualTradingCore.ts index fec001d..6955566 100644 --- a/frontend/src/hooks/useVirtualTradingCore.ts +++ b/frontend/src/hooks/useVirtualTradingCore.ts @@ -14,7 +14,7 @@ import { type StrategyDto, } from '../utils/backendApi'; import { useVirtualIndicatorSnapshots } from './useVirtualIndicatorSnapshots'; -import { useVirtualAutoTrade } from './useVirtualAutoTrade'; +import { useVirtualBackendTradeSignals } from './useVirtualBackendTradeSignals'; import { useVirtualTargetLiveStatus } from './useVirtualTargetLiveStatus'; import { loadVirtualSession, @@ -206,11 +206,10 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {} session.running, ); - useVirtualAutoTrade({ + useVirtualBackendTradeSignals({ enabled: paperTradingEnabled && paperAutoTradeEnabled, session, targets, - snapshots, tickers, paperAutoTradeBudgetPct, positions: summary?.positions, diff --git a/frontend/src/styles/backtestDashboard.css b/frontend/src/styles/backtestDashboard.css index 6857f1c..a168d82 100644 --- a/frontend/src/styles/backtestDashboard.css +++ b/frontend/src/styles/backtestDashboard.css @@ -439,8 +439,8 @@ white-space: nowrap; } -.btd-table td.buy { color: var(--down, #3b82f6); font-weight: 700; } -.btd-table td.sell { color: var(--up, #ef4444); font-weight: 700; } +.btd-table td.buy { color: var(--gc-trade-buy); font-weight: 700; } +.btd-table td.sell { color: var(--gc-trade-sell); font-weight: 700; } .btd-table td.up { color: #22c55e; font-weight: 700; } .btd-table td.down { color: #ef4444; font-weight: 700; } diff --git a/frontend/src/styles/dashboardCommand.css b/frontend/src/styles/dashboardCommand.css index 828320a..fe7620f 100644 --- a/frontend/src/styles/dashboardCommand.css +++ b/frontend/src/styles/dashboardCommand.css @@ -504,8 +504,8 @@ font-size: 0.68rem; } -.gc-signal-item--buy .gc-signal-arrow { color: #34d399; } -.gc-signal-item--sell .gc-signal-arrow { color: #f87171; } +.gc-signal-item--buy .gc-signal-arrow { color: var(--gc-trade-buy); } +.gc-signal-item--sell .gc-signal-arrow { color: var(--gc-trade-sell); } .gc-signal-main { display: flex; diff --git a/frontend/src/styles/paperDashboard.css b/frontend/src/styles/paperDashboard.css index d392790..6cde3b8 100644 --- a/frontend/src/styles/paperDashboard.css +++ b/frontend/src/styles/paperDashboard.css @@ -821,34 +821,35 @@ /* 매수·매도 제출 버튼: 실시간 차트 우측 패널(.top-submit--buy/sell)과 동일 */ .ptd-order-card .top-submit--buy { - background: #ef5350; - border-color: #ef5350; + background: var(--gc-trade-buy); + border-color: var(--gc-trade-buy); + color: #fff; } .ptd-order-card .top-submit--buy:hover { - background: #e53935; + background: var(--gc-trade-buy-hover); } .ptd-order-card .top-submit--sell { - background: #2962ff; - border-color: #2962ff; + background: var(--gc-trade-sell); + border-color: var(--gc-trade-sell); } .ptd-order-card .top-submit--sell:hover { - background: #1e53e5; + background: var(--gc-trade-sell-hover); } /* 매수·매도 카드 헤더: 실시간 차트 .rsp-trade-card--buy/sell 과 동일 */ .ptd-split-panel--right .ptd-split-card--top { - border-color: rgba(255, 107, 107, 0.35); - background: linear-gradient(180deg, rgba(255, 107, 107, 0.08) 0%, var(--bg3) 48px); + border-color: color-mix(in srgb, var(--gc-trade-buy) 40%, transparent); + background: linear-gradient(180deg, color-mix(in srgb, var(--gc-trade-buy) 10%, transparent) 0%, var(--bg3) 48px); } .ptd-split-panel--right .ptd-split-card--bottom { - border-color: rgba(77, 171, 247, 0.35); - background: linear-gradient(180deg, rgba(77, 171, 247, 0.08) 0%, var(--bg3) 48px); + border-color: color-mix(in srgb, var(--gc-trade-sell) 40%, transparent); + background: linear-gradient(180deg, color-mix(in srgb, var(--gc-trade-sell) 10%, transparent) 0%, var(--bg3) 48px); } .ptd-split-panel--right .ptd-split-card--top .ptd-split-card-head { - color: var(--up); + color: var(--gc-trade-buy); } .ptd-split-panel--right .ptd-split-card--bottom .ptd-split-card-head { - color: var(--down); + color: var(--gc-trade-sell); } .ptd-ob { diff --git a/frontend/src/styles/strategyEditorTheme.css b/frontend/src/styles/strategyEditorTheme.css index 7fe9389..0c73fd6 100644 --- a/frontend/src/styles/strategyEditorTheme.css +++ b/frontend/src/styles/strategyEditorTheme.css @@ -15,8 +15,8 @@ --se-gold: #e6c200; --se-success: #9ece6a; --se-danger: #f7768e; - --se-buy: #7aa2f7; - --se-sell: #f7768e; + --se-buy: var(--gc-trade-buy, #ef5350); + --se-sell: var(--gc-trade-sell, #4dabf7); --se-header-bg: linear-gradient(180deg, var(--bg2), var(--bg)); --se-header-border: var(--border); --se-title-gradient: linear-gradient(90deg, #e6c200, #ffe566); @@ -156,8 +156,8 @@ --se-gold: #f57f17; --se-success: #2e7d32; --se-danger: #c62828; - --se-buy: #1976d2; - --se-sell: #c62828; + --se-buy: var(--gc-trade-buy, #ef5350); + --se-sell: var(--gc-trade-sell, #4dabf7); --se-title-gradient: linear-gradient(90deg, #1565c0, #1976d2); --se-panel-card-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); --se-item-selected-bg: linear-gradient(135deg, rgba(25, 118, 210, 0.12), rgba(25, 118, 210, 0.04)); @@ -228,8 +228,8 @@ .se-page--blue { --se-gold: #ffd54f; --se-success: #69f0ae; - --se-buy: #5eb5ff; - --se-sell: #ff5555; + --se-buy: var(--gc-trade-buy, #ef5350); + --se-sell: var(--gc-trade-sell, #4dabf7); --se-title-gradient: linear-gradient(90deg, #ffd54f, #ffe082); --se-item-selected-bg: linear-gradient(135deg, rgba(255, 213, 79, 0.18), rgba(94, 181, 255, 0.08)); --se-strat-item-bg: color-mix(in srgb, var(--bg2) 92%, transparent); diff --git a/frontend/src/styles/tradeNotificationList.css b/frontend/src/styles/tradeNotificationList.css index f310ab5..ffb490b 100644 --- a/frontend/src/styles/tradeNotificationList.css +++ b/frontend/src/styles/tradeNotificationList.css @@ -21,11 +21,11 @@ } .tnl-row.tnl-row--receiving .tnl-summary-card--buy { - border-color: color-mix(in srgb, var(--accent, #3f7ef5) 35%, var(--se-border, var(--border))); + border-color: color-mix(in srgb, var(--gc-trade-buy) 35%, var(--se-border, var(--border))); } .tnl-row.tnl-row--receiving .tnl-summary-card--sell { - border-color: color-mix(in srgb, #ef5350 35%, var(--se-border, var(--border))); + border-color: color-mix(in srgb, var(--gc-trade-sell) 35%, var(--se-border, var(--border))); } .tnl-row.tnl-row--receiving.tnl-row--selected .tnl-summary-card--selected { @@ -425,11 +425,11 @@ } .tnl-summary-card--buy { - border-color: color-mix(in srgb, var(--accent, #3f7ef5) 35%, var(--se-border, var(--border))); + border-color: color-mix(in srgb, var(--gc-trade-buy) 35%, var(--se-border, var(--border))); } .tnl-summary-card--sell { - border-color: color-mix(in srgb, #ef5350 35%, var(--se-border, var(--border))); + border-color: color-mix(in srgb, var(--gc-trade-sell) 35%, var(--se-border, var(--border))); } .tnl-summary-panel { @@ -530,11 +530,11 @@ } .tnl-summary-arrow--buy { - color: var(--accent, #3f7ef5); + color: var(--gc-trade-buy); } .tnl-summary-arrow--sell { - color: #ef5350; + color: var(--gc-trade-sell); } .tnl-summary-pair { @@ -551,11 +551,11 @@ } .tnl-summary-type--buy { - color: var(--accent, #3f7ef5); + color: var(--gc-trade-buy); } .tnl-summary-type--sell { - color: #ef5350; + color: var(--gc-trade-sell); } .tnl-summary-quote-right { @@ -574,11 +574,11 @@ } .tnl-summary-price--buy { - color: var(--accent, #3f7ef5); + color: var(--gc-trade-buy); } .tnl-summary-price--sell { - color: #ef5350; + color: var(--gc-trade-sell); } /* 3행+: 상세 정보 */ diff --git a/frontend/src/styles/virtualTradingDashboard.css b/frontend/src/styles/virtualTradingDashboard.css index e5798fa..917f481 100644 --- a/frontend/src/styles/virtualTradingDashboard.css +++ b/frontend/src/styles/virtualTradingDashboard.css @@ -576,11 +576,11 @@ } .vtd-trade-item--buy { - border-color: color-mix(in srgb, var(--up, #ef5350) 22%, var(--se-border)); + border-color: color-mix(in srgb, var(--gc-trade-buy) 22%, var(--se-border)); } .vtd-trade-item--sell { - border-color: color-mix(in srgb, var(--down, #26a69a) 22%, var(--se-border)); + border-color: color-mix(in srgb, var(--gc-trade-sell) 22%, var(--se-border)); } .vtd-trade-side-badge { @@ -596,15 +596,15 @@ } .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); + color: var(--gc-trade-buy); + background: color-mix(in srgb, var(--gc-trade-buy) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--gc-trade-buy) 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); + color: var(--gc-trade-sell); + background: color-mix(in srgb, var(--gc-trade-sell) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--gc-trade-sell) 28%, transparent); } .vtd-trade-quote { @@ -1322,9 +1322,13 @@ } /* 매수·매도 타이밍 — 카드 테두리 강조 비활성 (신호등·패널에서만 표시) */ -.vtd-card--timing-buy, +.vtd-card--timing-buy { + border-color: color-mix(in srgb, var(--gc-trade-buy) 40%, var(--se-border)); + box-shadow: none; +} + .vtd-card--timing-sell { - border-color: color-mix(in srgb, var(--se-gold) 35%, var(--se-border)); + border-color: color-mix(in srgb, var(--gc-trade-sell) 40%, var(--se-border)); box-shadow: none; } diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index b31c3e6..acda357 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -25,6 +25,7 @@ import { BollingerBandFillPrimitive } from './BollingerBandFillPrimitive'; import { resolveBbBandBackground } from './bollingerConfig'; import type { OHLCVBar, ChartType, Theme, IndicatorConfig, Timeframe } from '../types'; import { formatChartAxisPrice, formatIndicatorAxisPrice } from './dataGenerator'; +import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from './tradeSignalColors'; import { DEFAULT_CHART_TIME_FORMAT, formatUnixWithChartPattern, @@ -1181,7 +1182,7 @@ export class ChartManager { time: s.time, position: buy ? ('belowBar' as const) : ('aboveBar' as const), shape: buy ? ('arrowUp' as const) : ('arrowDown' as const), - color: s.type === 'PARTIAL_SELL' ? '#FF9800' : (buy ? '#26A69A' : '#EF5350'), + color: s.type === 'PARTIAL_SELL' ? '#FF9800' : (buy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR), text, }; }); @@ -1213,7 +1214,7 @@ export class ChartManager { time: m.time, position: buy ? ('belowBar' as const) : ('aboveBar' as const), shape: buy ? ('arrowUp' as const) : ('arrowDown' as const), - color: buy ? '#00BCD4' : '#FF9800', // 백테스팅과 구별: 청록/주황 + color: buy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR, text, }; }); diff --git a/frontend/src/utils/strategyConditionSerde.ts b/frontend/src/utils/strategyConditionSerde.ts index aa6bad9..8889f54 100644 --- a/frontend/src/utils/strategyConditionSerde.ts +++ b/frontend/src/utils/strategyConditionSerde.ts @@ -1,3 +1,7 @@ +/** + * 전략 편집기 DSL 직렬화 — DB 평가 원천은 buy/sell condition JSON 만. + * flow_layout 은 UI(좌표·START 메타); Logic Expression 은 저장하지 않음. + */ import { normalizeLogicRootForPersistence } from './strategyConditionNormalize'; import type { LogicNode, ConditionDSL } from './strategyTypes'; import { generateNodeId } from './strategyTypes'; diff --git a/frontend/src/utils/tradeSignalColors.ts b/frontend/src/utils/tradeSignalColors.ts new file mode 100644 index 0000000..3ebc044 --- /dev/null +++ b/frontend/src/utils/tradeSignalColors.ts @@ -0,0 +1,5 @@ +/** 프로젝트 공통 — 매매 시그널 색 (매수: 빨강, 매도: 파랑) */ +export const TRADE_BUY_COLOR = '#ef5350'; +export const TRADE_SELL_COLOR = '#4dabf7'; +export const TRADE_BUY_COLOR_HOVER = '#e53935'; +export const TRADE_SELL_COLOR_HOVER = '#339af0'; diff --git a/frontend/src/utils/virtualSignalMetrics.ts b/frontend/src/utils/virtualSignalMetrics.ts index 825e40e..d56cc39 100644 --- a/frontend/src/utils/virtualSignalMetrics.ts +++ b/frontend/src/utils/virtualSignalMetrics.ts @@ -1,7 +1,6 @@ import { coerceFiniteNumber } from './safeFormat'; import { formatIndicatorValue, - isConditionMet, type VirtualConditionRow, } from './virtualStrategyConditions'; @@ -10,7 +9,7 @@ export type ConditionStatus = 'match' | 'pending' | 'nomatch' | 'unknown'; export interface ConditionMetric { row: VirtualConditionRow & { currentValue: number | null }; status: ConditionStatus; - /** 조건 매칭율 0~100 */ + /** 조건 매칭율 0~100 — 백엔드 Ta4j satisfied 만 반영 */ matchRate: number; /** @deprecated matchRate 와 동일 — 하위 호환 */ progress: number | null; @@ -23,102 +22,28 @@ export function resolveHeatTier(matchRate: number, status: ConditionStatus): Hea if (status === 'match' || matchRate >= 100) return 'match'; if (status === 'pending') return 'pending'; if (status === 'nomatch') return 'nomatch'; - if (matchRate >= 45) return 'pending'; return 'nomatch'; } export type TrafficLightState = 'red' | 'yellow' | 'blue'; -const PENDING_PROGRESS = 72; - -/** 목표값 대비 현재값 접근률 (0~100, 충족 시 100) */ -export function computeConditionProgress( - conditionType: string, - current: number | null | unknown, - target: number | null | unknown, -): number | null { - const cur = coerceFiniteNumber(current); - const tgt = coerceFiniteNumber(target); - if (cur == null || tgt == null) return null; - const met = isConditionMet(conditionType, cur, tgt); - if (met === true) return 100; - - const absTarget = Math.abs(tgt); - const scale = absTarget > 1e-9 ? absTarget : Math.max(Math.abs(cur), 1); - - switch (conditionType) { - case 'LT': - case 'CROSS_DOWN': - case 'LTE': { - if (cur <= tgt) return 100; - const gap = cur - tgt; - return Math.min(99, Math.max(0, 100 - (gap / scale) * 100)); - } - case 'GT': - case 'CROSS_UP': - case 'GTE': { - if (cur >= tgt) return 100; - if (tgt <= 0 && cur <= 0) return 0; - return Math.min(99, Math.max(0, (cur / scale) * 100)); - } - case 'EQ': { - const diff = Math.abs(cur - tgt); - return Math.min(99, Math.max(0, 100 - (diff / scale) * 100)); - } - default: - return Math.min(99, Math.max(0, 100 - (Math.abs(cur - tgt) / scale) * 100)); - } -} - -const CROSS_CONDITION_TYPES = new Set([ - 'CROSS_UP', 'CROSS_DOWN', 'SLOPE_UP', 'SLOPE_DOWN', -]); - -/** 조건별 매칭율 (0~100) — 백엔드 Ta4j satisfied 우선 */ +/** 조건별 매칭율 (0~100) — 백엔드 live-conditions 의 satisfied 만 사용 */ export function computeConditionMatchRate( row: VirtualConditionRow & { currentValue: number | null }, ): number { if (row.satisfied === true) return 100; - if (row.satisfied === false) { - if (CROSS_CONDITION_TYPES.has(row.conditionType)) return 0; - const progress = computeConditionProgress( - row.conditionType, - row.currentValue, - row.targetValue, - ); - if (progress == null) return 0; - return Math.min(99, Math.max(0, Math.round(progress))); - } - if (row.currentValue == null) return 0; - const progress = computeConditionProgress( - row.conditionType, - row.currentValue, - row.targetValue, - ); - if (progress == null) return 0; - return Math.min(99, Math.max(0, Math.round(progress))); + if (row.satisfied === false) return 0; + return 0; } export function getConditionStatus( - conditionType: string, - current: number | null, - target: number | null, + _conditionType: string, + _current: number | null, + _target: number | null, satisfied?: boolean | null, ): ConditionStatus { if (satisfied === true) return 'match'; - if (satisfied === false) { - if (CROSS_CONDITION_TYPES.has(conditionType)) return 'nomatch'; - const progress = computeConditionProgress(conditionType, current, target); - if (progress != null && progress >= PENDING_PROGRESS) return 'pending'; - return 'nomatch'; - } - const met = isConditionMet(conditionType, current, target); - if (met === true) return 'match'; - if (met === false) { - const progress = computeConditionProgress(conditionType, current, target); - if (progress != null && progress >= PENDING_PROGRESS) return 'pending'; - return 'nomatch'; - } + if (satisfied === false) return 'nomatch'; return 'unknown'; } @@ -196,35 +121,11 @@ export function computeMatchRate( export function getTrafficLightState(matchRate: number, metrics: ConditionMetric[]): TrafficLightState { if (matchRate >= 100) return 'blue'; - const hasPending = metrics.some(m => m.status === 'pending'); const hasPartial = metrics.some(m => m.status === 'match'); - if (hasPending || hasPartial || matchRate >= 40) return 'yellow'; + if (hasPartial || matchRate >= 40) return 'yellow'; return 'red'; } -/** 매수·매도 조건 충족률 기준 타이밍 (TradeAlertModal: 매수=블루, 매도=레드) */ -export type VirtualTradeTiming = 'buy' | 'sell' | 'neutral'; - -function sideMatchRate(metrics: ConditionMetric[], side: 'buy' | 'sell'): number { - const rows = metrics.filter(m => m.row.side === side && m.status !== 'unknown'); - if (rows.length === 0) return 0; - const met = rows.filter(m => m.status === 'match').length; - return Math.round((met / rows.length) * 100); -} - -export function resolveVirtualTradeTiming(metrics: ConditionMetric[]): VirtualTradeTiming { - const buyRate = sideMatchRate(metrics, 'buy'); - const sellRate = sideMatchRate(metrics, 'sell'); - if (buyRate === 0 && sellRate === 0) return 'neutral'; - if (buyRate >= 100 && buyRate > sellRate) return 'buy'; - if (sellRate >= 100 && sellRate > buyRate) return 'sell'; - if (buyRate >= 100 && sellRate < 100) return 'buy'; - if (sellRate >= 100 && buyRate < 100) return 'sell'; - if (buyRate > sellRate && buyRate >= 50) return 'buy'; - if (sellRate > buyRate && sellRate >= 50) return 'sell'; - return 'neutral'; -} - export function formatUpdatedTime(ts: number | undefined): string { if (!ts) return '—'; return new Date(ts).toLocaleTimeString('ko-KR', { diff --git a/frontend/src/utils/virtualStrategyConditions.ts b/frontend/src/utils/virtualStrategyConditions.ts index 686738e..ad849da 100644 --- a/frontend/src/utils/virtualStrategyConditions.ts +++ b/frontend/src/utils/virtualStrategyConditions.ts @@ -83,26 +83,6 @@ export function extractVirtualConditions(strategy: StrategyDto | null | undefine return out; } -/** 조건 충족 여부 (단순 임계값 비교) */ -export function isConditionMet( - conditionType: string, - current: number | null | unknown, - target: number | null | unknown, -): boolean | null { - const cur = coerceFiniteNumber(current); - const tgt = coerceFiniteNumber(target); - if (cur == null || tgt == null) return null; - switch (conditionType) { - case 'GT': case 'CROSS_UP': return cur > tgt; - case 'LT': case 'CROSS_DOWN': return cur < tgt; - case 'GTE': return cur >= tgt; - case 'LTE': return cur <= tgt; - case 'EQ': return Math.abs(cur - tgt) < 1e-6; - case 'NEQ': return Math.abs(cur - tgt) >= 1e-6; - default: return null; - } -} - export function formatIndicatorValue(v: number | null | unknown): string { const n = coerceFiniteNumber(v); if (n == null) return '—'; diff --git a/전략편집기 로직 및 동작흐름.md b/전략편집기 로직 및 동작흐름.md index 8fe2108..1f10b53 100644 --- a/전략편집기 로직 및 동작흐름.md +++ b/전략편집기 로직 및 동작흐름.md @@ -18,6 +18,26 @@ goldenChart **전략편집기(Strategy Editor)** 의 화면 구성, 편집·저 전략편집기에서 저장하는 것은 **조건 트리 JSON**(`buyCondition`, `sellCondition`)이며, 노드 좌표·팔레트 사용자 정의 항목 등은 **브라우저 localStorage**에만 둘 수 있습니다. +### 1.1 아키텍처 원칙 (단일 평가 원천) + +| 계층 | 역할 | +|------|------| +| **전략편집기** | `buy_condition_json` / `sell_condition_json` 만 DB에 저장. 그래프·목록 빌더는 **동일 DSL** 공유, 표현만 다름 | +| **Logic Expression** | 저장된 DSL의 **설명용 미리보기** — 별도 조건 컬럼 없음 | +| **`flow_layout_json`** | 캔버스 좌표·START 메타 등 **UI** — Ta4j 평가 입력이 아님 (DSL과 동기 저장) | +| **백엔드** | Ta4j로 **유일한** BUY/SELL·조건 충족 평가 | +| **프론트 가상투자 UI** | `GET /strategy/live-conditions` 표시·일치율만. **프론트에서 조건 재평가하지 않음** | +| **가상 자동매매** | 백엔드 STOMP `signal` (BUY/SELL) 수신 시에만 모의 체결 | + +### 1.2 봉 마감별 전략 체크 (스킵 방지) + +1. 업비트 틱 → `BarBuilder.commitBar` — 1m 확정 시 전략 DSL에 포함된 상위봉(3m·5m·10m·15m …)까지 전파 +2. 각 **상위봉 마감** 시 `BarCloseStrategyEvaluationService.onMaturedBarClose` — `StrategyConditionTimeframeService.usesTimeframe` 이 true 인 분봉만 평가 +3. WS 갭 → `CandleGapBackfillService` REST 병합 후 `evaluateRecentMaturedBarsAfterBackfill` 로 병합 구간 마감봉 보정 +4. `REALTIME_TICK` 실행 방식은 추가로 `LiveStrategyScheduler`(3초)가 **동일 DSL 분봉**만 순회 + +예: 비트코인 전략 START에 `3m,5m,10m,15m` 이면 각 분봉 **마감봉마다** 백엔드 평가가 1회씩 실행되어야 하며, 평가 분봉은 DSL에 없는 `1h` 등에서는 실행되지 않음. + --- ## 2. 화면 구성 (`StrategyEditorPage`) @@ -581,7 +601,8 @@ flowchart TB | `backend/.../service/BacktestingService.java` | 백테스트 | | `backend/.../service/LiveConditionStatusService.java` | 조건 현황 API | | `backend/.../service/StrategyConditionTimeframeService.java` | DSL 분봉 수집 | -| `backend/.../websocket/BarBuilder.java` | 봉 조립·마감 평가 | +| `backend/.../websocket/BarBuilder.java` | 봉 조립·마감 시 평가 트리거 | +| `backend/.../service/BarCloseStrategyEvaluationService.java` | 봉 마감 전략 평가 단일 진입점 | | `backend/.../websocket/LiveStrategyScheduler.java` | 3초 REALTIME_TICK | | `backend/.../websocket/TradingWebSocketBroker.java` | STOMP 발행 | | `backend/.../storage/Ta4jStorage.java` | BarSeries 저장소 | @@ -597,7 +618,9 @@ flowchart TB 5. **데이터** — `Ta4jStorage.exists(market, candleType)`, warm-up(`pinCandleWatch`) 6. **포지션 모드** — `LONG_ONLY`에서 이미 포지션일 때 BUY 무시 7. **STOMP** — 구독 토픽 `/sub/charts/{market}/{candleType}` 와 평가 분봉 일치 -8. **UI 현황 vs 시그널** — `live-conditions` matchRate ≠ 실제 BUY/SELL 발화 조건 +8. **UI 현황 vs 시그널** — `live-conditions` matchRate ≠ 실제 BUY/SELL 발화 조건 (UI는 리프별 Rule, 시그널은 트리 전체) +9. **마감봉 평가** — `BarCloseStrategyEvaluationService` + DSL `usesTimeframe` 게이트; 갭은 `CandleGapBackfillService` 후 보정 +10. **프론트 평가 금지** — 가상투자 일치율·자동매매는 백엔드 API/STOMP 만 사용 ---