종목변경 시 캔들차트 사라지는 현상 수정

This commit is contained in:
Macbook
2026-05-24 21:51:27 +09:00
parent af230a4233
commit 3c66c562d8
14 changed files with 288 additions and 219 deletions
@@ -1,10 +1,13 @@
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.websocket.DynamicSubscriptionManager;
import com.goldenchart.websocket.UpbitWebSocketClient;
import lombok.RequiredArgsConstructor;
@@ -23,9 +26,10 @@ import org.springframework.stereotype.Component;
public class LiveStrategyStartupRunner implements ApplicationRunner {
private final AppSettingsService appSettingsService;
private final GcWatchlistRepository watchlistRepo;
private final DynamicSubscriptionManager subscriptionManager;
private final LiveStrategyEvaluator liveStrategyEvaluator;
private final GcWatchlistRepository watchlistRepo;
private final GcLiveStrategySettingsRepository liveSettingsRepo;
private final DynamicSubscriptionManager subscriptionManager;
private final LiveStrategyEvaluator liveStrategyEvaluator;
private final UpbitWebSocketClient upbitWebSocketClient;
@Override
@@ -33,13 +37,18 @@ public class LiveStrategyStartupRunner implements ApplicationRunner {
// 디바이스별 전역 설정이 켜져 있고 전략이 지정된 경우, DB 관심종목 전체 고정 구독
int pinned = 0;
for (GcWatchlist w : watchlistRepo.findAll()) {
Long userId = w.getUserId();
String deviceId = w.getDeviceId();
if (deviceId == null) continue;
GcAppSettings app = appSettingsService.getEntity(null, deviceId);
if (userId == null && deviceId == null) continue;
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
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);
}
liveStrategyEvaluator.invalidateCache(w.getSymbol());
pinned++;
}
@@ -49,4 +58,20 @@ public class LiveStrategyStartupRunner implements ApplicationRunner {
// WS 연결 직후 addMarket 이 구독 패킷을 놓치는 경우 대비
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");
}
if (w.getDeviceId() != null) {
return liveSettingsRepo.findByDeviceIdAndMarket(w.getDeviceId(), w.getSymbol())
.map(GcLiveStrategySettings::getCandleType)
.map(LiveStrategyTimeframeService::normalize)
.orElse("1m");
}
return "1m";
}
}
@@ -75,11 +75,20 @@ public class LiveStrategySettingsService {
/** 관심종목 등록 시 — DB 관심종목 = 전략 체크 대상 행 생성/갱신 */
public void enableForWatchlistMarket(Long userId, String deviceId, String market) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
LiveStrategySettingsDto template = defaultDtoFromApp(market, app);
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);
});
template.setLiveCheck(true);
upsertEntity(userId, deviceId, template);
pinIfReady(market, template);
log.debug("[LiveStrategy] watchlist add → enabled {}", market);
log.debug("[LiveStrategy] watchlist add → enabled {} candleType={}", market, template.getCandleType());
}
/** 관심종목 해제 시 — 해당 종목 전략 체크 비활성화 */
@@ -163,10 +172,17 @@ public class LiveStrategySettingsService {
&& app.getLiveStrategyId() != null;
String candleType = template != null && template.getCandleType() != null
? LiveStrategyTimeframeService.normalize(template.getCandleType())
: null;
: resolveSharedCandleType(userId, deviceId);
for (String symbol : listWatchlistSymbols(userId, deviceId)) {
LiveStrategySettingsDto row = defaultDtoFromApp(symbol, app);
LiveStrategySettingsDto row = findEntity(userId, deviceId, symbol)
.map(e -> mergeGlobalTemplate(toDto(e), app, symbol))
.orElseGet(() -> defaultDtoFromApp(symbol, app));
row.setLiveCheck(active);
row.setStrategyId(app.getLiveStrategyId());
row.setExecutionType(app.getLiveExecutionType() != null
? app.getLiveExecutionType() : row.getExecutionType());
row.setPositionMode(app.getLivePositionMode() != null
? app.getLivePositionMode() : row.getPositionMode());
if (candleType != null) {
row.setCandleType(candleType);
}
@@ -177,6 +193,17 @@ public class LiveStrategySettingsService {
}
}
/** 관심종목 중 이미 저장된 평가 분봉 (전역 템플릿 동기화용) */
private String resolveSharedCandleType(Long userId, String deviceId) {
for (String symbol : listWatchlistSymbols(userId, deviceId)) {
Optional<GcLiveStrategySettings> row = findEntity(userId, deviceId, symbol);
if (row.isPresent() && row.get().getCandleType() != null) {
return LiveStrategyTimeframeService.normalize(row.get().getCandleType());
}
}
return null;
}
private LiveStrategySettingsDto upsertEntity(Long userId, String deviceId,
LiveStrategySettingsDto dto) {
String market = dto.getMarket() != null ? dto.getMarket() : "KRW-BTC";
@@ -207,7 +234,10 @@ public class LiveStrategySettingsService {
if (Boolean.TRUE.equals(dto.isLiveCheck()) && dto.getStrategyId() != null) {
String ct = LiveStrategyTimeframeService.normalize(
dto.getCandleType() != null ? dto.getCandleType() : "1m");
subscriptionManager.ensureMarketPinned(market, ct);
subscriptionManager.ensureMarketPinned(market, "1m");
if (!"1m".equals(ct)) {
subscriptionManager.ensureMarketPinned(market, ct);
}
}
}
@@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Comparator;
import java.util.Optional;
import java.util.Set;
@@ -44,9 +45,12 @@ public class LiveStrategyTimeframeService {
@Transactional(readOnly = true)
public String resolveCandleTypeForMarket(String market) {
return repo.findActiveByMarket(market).stream()
.filter(s -> s.getCandleType() != null && !s.getCandleType().isBlank())
.max(Comparator.comparing(
GcLiveStrategySettings::getUpdatedAt,
Comparator.nullsLast(Comparator.naturalOrder())))
.map(GcLiveStrategySettings::getCandleType)
.map(LiveStrategyTimeframeService::normalize)
.findFirst()
.orElse("1m");
}
@@ -55,24 +55,6 @@ public class BarBuilder {
/** 진행 중인 1분봉 상태 (market → PartialBar) */
private final ConcurrentHashMap<String, PartialBar> partialBars = new ConcurrentHashMap<>();
/**
* 봉 마감 직후 전략 판정 시그널 임시 보관 (market → "BUY"|"SELL"|"NONE").
* publishCurrentBar 에서 단 1회 실어 보낸 뒤 제거된다.
*/
private final ConcurrentHashMap<String, String> signalOnClose = new ConcurrentHashMap<>();
/**
* 시그널이 발생한 봉의 barStart epoch-second 임시 보관.
* publishCurrentBar 에서 signal 과 함께 사용되고 제거된다.
*/
private final ConcurrentHashMap<String, Long> signalBarTimeEpoch = new ConcurrentHashMap<>();
/**
* 시그널이 발생한 봉의 최종 close 가격 임시 보관.
* publishCurrentBar 에서 시그널 메시지의 price 로 사용된다.
*/
private final ConcurrentHashMap<String, Double> signalBarClose = new ConcurrentHashMap<>();
// ── 공개 API ──────────────────────────────────────────────────────────────
/**
@@ -136,7 +118,7 @@ public class BarBuilder {
String evalCandleType = timeframeService.resolveCandleTypeForMarket(market);
BarSeries series1m = ta4jStorage.getOrCreate(market, "1m");
if ("1m".equals(evalCandleType)) {
evaluateStrategyOnBarClose(market, "1m", series1m.getEndIndex(), partial, bar, true);
evaluateStrategyOnBarClose(market, "1m", series1m.getEndIndex(), bar);
}
// 상위 타임프레임 전파
@@ -167,7 +149,8 @@ public class BarBuilder {
BarSeries upperAfter = ta4jStorage.getOrCreate(market, type);
if (type.equals(evalCandleType)) {
int maturedUpper = Math.max(upperAfter.getBeginIndex(), upperAfter.getEndIndex() - 1);
evaluateStrategyOnBarClose(market, type, maturedUpper, partial, bar, false);
Bar maturedUpperBar = upperAfter.getBar(maturedUpper);
evaluateStrategyOnBarClose(market, type, maturedUpper, maturedUpperBar);
}
}
}
@@ -175,12 +158,10 @@ public class BarBuilder {
}
/**
* 봉 마감 시점 전략 평가 + 시그널 저장 + 주문 큐 적재.
*
* @param publishStompSignal 1m 만 STOMP 시그널 필드에 실어 보냄
* 봉 마감 시점 전략 평가 + 시그널 STOMP 발행(평가 분봉 채널) + DB/주문 큐 적재.
*/
private void evaluateStrategyOnBarClose(String market, String candleType, int maturedIndex,
PartialBar partial, Bar bar, boolean publishStompSignal) {
Bar signalBar) {
String closeSignal = liveStrategyEvaluator.evaluateCandleClose(market, candleType, maturedIndex);
String signalExecType = "CANDLE_CLOSE";
@@ -189,10 +170,8 @@ public class BarBuilder {
signalExecType = "REALTIME_TICK";
}
if (publishStompSignal) {
signalOnClose.put(market, closeSignal);
signalBarTimeEpoch.put(market, partial.barStart.toEpochSecond());
signalBarClose.put(market, partial.close);
if ("BUY".equals(closeSignal) || "SELL".equals(closeSignal)) {
publishStrategySignal(market, candleType, signalBar, closeSignal);
}
if (!"BUY".equals(closeSignal) && !"SELL".equals(closeSignal)) return;
@@ -202,70 +181,52 @@ public class BarBuilder {
GcLiveStrategySettings activeSetting = liveSettingsRepo.findActiveByMarket(market)
.stream().filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck())
&& execType.equals(s.getExecutionType())).findFirst().orElse(null);
long candleTimeEpoch = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
long candleTimeEpoch = signalBar.getEndTime().getEpochSecond()
- signalBar.getTimePeriod().getSeconds();
String devId = activeSetting != null ? activeSetting.getDeviceId() : null;
Long uid = activeSetting != null ? activeSetting.getUserId() : null;
Long stratId = activeSetting != null ? activeSetting.getStrategyId() : null;
tradeSignalService.save(
devId, uid, market, stratId, null,
closeSignal, partial.close, candleTimeEpoch, candleType, execType);
closeSignal, signalBar.getClosePrice().doubleValue(),
candleTimeEpoch, candleType, execType);
if (devId != null) {
orderExecutionQueue.submitSignal(devId, uid, market, stratId, closeSignal, partial.close);
orderExecutionQueue.submitSignal(devId, uid, market, stratId, closeSignal,
signalBar.getClosePrice().doubleValue());
}
} catch (Exception e) {
log.warn("[BarBuilder] 시그널 처리 실패 ({}/{}): {}", market, candleType, e.getMessage());
}
}
/**
* 현재 진행 중인 1분봉을 STOMP 로 발행.
*
* <p><b>핵심 버그 수정 (백테스팅 데이터 오염 방지)</b><br>
* 분 전환 시점에 {@code commitBar} 가 {@code signalOnClose = "NONE"} 을 항상 설정하므로,
* 이전 코드에서는 {@code signal != null} 조건만으로 {@code closedBarTime} 을 결정했다.
* 그 결과 분 전환 첫 틱에 발행되는 STOMP 메시지가:
* - time = 직전 확정봉 barStart (잘못된 타임스탬프)
* - close = 새 provisional 봉 첫 틱 가격 (잘못된 close)
* 가 되어 프론트엔드 차트에서 직전 확정봉의 close 를 새 봉의 첫 틱으로 덮어쓰게 된다.
* 이 오염된 close 값은 백테스팅 전송 데이터로도 활용되어 CCI 계산이 왜곡된다.
*
* <p>수정 원칙:
* <ul>
* <li>실제 BUY/SELL 시그널 → 마감봉 시각(closedBarTime)과 마감봉 close 를 사용</li>
* <li>"NONE" 또는 null → 현재 진행 중인 봉 시각(p.barStart)과 실시간 close 를 사용</li>
* </ul>
*/
/** 평가 분봉 STOMP 채널로 봉 마감 시그널 발행 */
private void publishStrategySignal(String market, String candleType, Bar bar, String signal) {
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)
.build();
broker.publish(market, candleType, dto);
log.info("[BarBuilder] bar-close signal={} market={} candleType={}", signal, market, candleType);
}
/** 현재 진행 중인 1분봉을 STOMP 로 발행 (실시간 틱 렌더링용, 시그널 없음). */
private void publishCurrentBar(String market) {
PartialBar p = partialBars.get(market);
if (p == null) return;
String signal = signalOnClose.remove(market);
// ★ "NONE" 은 실제 시그널이 아니므로 closedBarTime 을 사용하지 않는다.
// 이전에는 signal != null 로만 판단해서 "NONE" 도 시각 보정 대상이 되었고,
// 결과적으로 새 봉의 첫 틱 데이터가 직전 확정봉 시각으로 발행되어 차트 데이터가 오염됐다.
boolean isRealSignal = "BUY".equals(signal) || "SELL".equals(signal);
Long closedBarTime = isRealSignal ? signalBarTimeEpoch.remove(market) : null;
Double closedBarClose = isRealSignal ? signalBarClose.remove(market) : null;
// 잔여 맵 엔트리 정리 (실제 시그널 없는 경우)
if (!isRealSignal) {
signalBarTimeEpoch.remove(market);
signalBarClose.remove(market);
}
long barTime = (closedBarTime != null) ? closedBarTime : p.barStart.toEpochSecond();
double barClose = (closedBarClose != null) ? closedBarClose : p.close;
CandleBarDto dto = CandleBarDto.builder()
.time(barTime)
.time(p.barStart.toEpochSecond())
.open(p.open)
.high(p.high)
.low(p.low)
.close(barClose)
.close(p.close)
.volume(p.volume)
.signal(isRealSignal ? signal : null) // null 이면 @JsonInclude(NON_NULL) 로 직렬화 제외
.build();
broker.publish(market, "1m", dto);
publishUpperTimeframes(market);
@@ -42,7 +42,6 @@ public class LiveStrategyScheduler {
private final BarBuilder barBuilder;
private final TradeSignalService tradeSignalService;
private final OrderExecutionQueue orderExecutionQueue;
private final LiveStrategyTimeframeService timeframeService;
/**
* 3초마다 REALTIME_TICK 설정 종목을 순회하여 전략 판정 후 시그널 발행.
@@ -58,8 +57,8 @@ public class LiveStrategyScheduler {
if (s.getStrategyId() == null) continue;
String market = s.getMarket();
String candleType = timeframeService.resolveCandleType(
market, s.getUserId(), s.getDeviceId());
String candleType = LiveStrategyTimeframeService.normalize(
s.getCandleType() != null ? s.getCandleType() : "1m");
if (!ta4jStorage.exists(market, candleType)) continue;