종목변경 시 캔들차트 사라지는 현상 수정
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user