매수, 매도 색상 수정

This commit is contained in:
Macbook
2026-05-31 01:23:47 +09:00
parent ba4ef5e230
commit 9d7dddfa57
24 changed files with 573 additions and 441 deletions
@@ -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·갭 백필 등 단일 진입점.
*
* <p>활성 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<String, Long> 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<GcLiveStrategySettings> 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)");
}
}
}
@@ -67,17 +67,12 @@ public class StrategyConditionTimeframeService {
GcStrategy strategy = opt.get();
ensureDslRepaired(strategy);
Set<String> fromFlowLayout = collectFromFlowLayoutJson(strategy.getFlowLayoutJson());
if (!fromFlowLayout.isEmpty()) {
return sanitizeEvaluationTimeframes(fromFlowLayout, strategy);
}
Set<String> buyOut = new LinkedHashSet<>();
Set<String> 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<String> fromFlowLayout = collectFromFlowLayoutJson(strategy.getFlowLayoutJson());
if (!fromFlowLayout.isEmpty()) {
return sanitizeEvaluationTimeframes(fromFlowLayout, strategy);
}
String fromName = StrategyDslTimeframeNormalizer.inferFromStrategyName(strategy.getName());
return Set.of(fromName != null ? fromName : "1m");
}
@@ -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;
}
@@ -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<GcLiveStrategySettings> 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);
@@ -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();
}
}