goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
@@ -0,0 +1,362 @@
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.TradeSignalService;
import com.goldenchart.trading.pipeline.OrderExecutionQueue;
import com.goldenchart.storage.Ta4jStorage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import org.ta4j.core.bars.TimeBarBuilderFactory;
import org.ta4j.core.num.DoubleNumFactory;
import java.time.Duration;
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;
/**
* 업비트 WebSocket 틱(Trade) 데이터를 수신하여 분봉 단위 캔들을 조립하는 빌더.
*
* 명세서 3.2 준수:
* - 동일 분(minute) 내 틱 → series.addPrice(price, volume) 로 현재 Bar 갱신
* - 분이 바뀌는 틱 → 이전 Bar 를 확정하고 series.addBar(newBar) 로 새 캔들 열기
* - 1분봉 확정 시 상위 타임프레임(3m, 5m, 15m, 30m, 1h, 4h, 1d)으로 집계 전파
*
* 지원 캔들 타입: 1m, 3m, 5m, 15m, 30m, 1h, 4h, 1d
*/
@Component
@RequiredArgsConstructor
@Slf4j
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 LiveStrategyTimeframeService timeframeService;
/** 상위 집계 타임프레임 (분 단위 집계 주기: 3, 5, 15, 30, 60, 240, 1440) */
private static final int[] UPPER_MINUTES = {3, 5, 15, 30, 60, 240, 1440};
private static final String[] UPPER_TYPES = {"3m", "5m", "15m", "30m", "1h", "4h", "1d"};
/** 진행 중인 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 ──────────────────────────────────────────────────────────────
/**
* 업비트 trade 틱을 처리하여 1분봉을 조립한다.
*
* @param market 마켓 코드 (e.g. "KRW-BTC")
* @param tradePrice 체결가
* @param tradeVolume 체결량
* @param tradeTimeMs 체결 Unix 밀리초
*/
public void onTick(String market, double tradePrice, double tradeVolume, long tradeTimeMs) {
ZonedDateTime tickTime = ZonedDateTime.ofInstant(
java.time.Instant.ofEpochMilli(tradeTimeMs), ZoneOffset.UTC);
// 해당 분의 시작 시간 (초 이하 제거)
ZonedDateTime barStart = tickTime.withSecond(0).withNano(0);
PartialBar current = partialBars.get(market);
if (current == null || !current.barStart.equals(barStart)) {
// ── 분 변경: 이전 Bar 확정 및 신규 Bar 열기 ─────────────────────
if (current != null) {
commitBar(market, current); // 이전 Bar 확정
}
// 신규 Bar 시작
PartialBar newBar = new PartialBar(barStart, tradePrice, tradeVolume);
partialBars.put(market, newBar);
// 새 분이 시작되는 첫 틱을 Ta4jStorage 에 provisional bar 로 추가.
// 이렇게 해야 이후 동일 분 틱에서 updateLastBar 가 직전 확정봉을 오염시키지 않는다.
Duration d1m = Duration.ofMinutes(1);
ZonedDateTime endTime = barStart.plus(d1m);
Bar provisionalBar = buildTa4jBar(newBar, d1m, endTime);
ta4jStorage.addBar(market, "1m", provisionalBar);
} else {
// ── 동일 분: 진행 중인 Bar 갱신 ────────────────────────────────
current.update(tradePrice, tradeVolume);
// Ta4jStorage 마지막 봉(현재 진행 중인 provisional bar)을 실시간 갱신
ta4jStorage.updateLastBar(market, "1m", tradePrice, tradeVolume);
}
// 현재 1분봉 상태를 STOMP 로 즉시 발행 (실시간 틱 렌더링용)
publishCurrentBar(market);
}
// ── 내부 처리 ──────────────────────────────────────────────────────────────
/** 완성된 1분봉을 Ta4jStorage 에 저장하고 상위 타임프레임에 전파 */
private void commitBar(String market, PartialBar partial) {
Duration d1m = Duration.ofMinutes(1);
ZonedDateTime endTime = partial.barStart.plus(d1m);
// Ta4j Bar 생성
Bar bar = buildTa4jBar(partial, d1m, endTime);
// 1분봉 Ta4jStorage 저장
ta4jStorage.addBar(market, "1m", bar);
log.debug("[BarBuilder] 1m Bar 확정: {} @ {}", market, endTime);
String evalCandleType = timeframeService.resolveCandleTypeForMarket(market);
BarSeries series1m = ta4jStorage.getOrCreate(market, "1m");
if ("1m".equals(evalCandleType)) {
evaluateStrategyOnBarClose(market, "1m", series1m.getEndIndex(), partial, bar, true);
}
// 상위 타임프레임 전파
for (int i = 0; i < UPPER_MINUTES.length; i++) {
int minutes = UPPER_MINUTES[i];
String type = UPPER_TYPES[i];
// 해당 상위 봉의 시작 시간 계산
ZonedDateTime upperStart = alignToInterval(partial.barStart, minutes);
ZonedDateTime upperEnd = upperStart.plusMinutes(minutes);
BarSeries upperSeries = ta4jStorage.getOrCreate(market, type);
if (upperSeries.isEmpty()) {
// 새 상위봉 열기
Bar upperBar = buildTa4jBar(partial, Duration.ofMinutes(minutes), upperEnd);
ta4jStorage.addBar(market, type, upperBar);
} else {
Bar lastUpper = upperSeries.getLastBar();
// Ta4j 0.22: getEndTime() → Instant
Instant lastUpperEndInstant = lastUpper.getEndTime();
Instant upperEndInstant = upperEnd.toInstant();
if (lastUpperEndInstant.equals(upperEndInstant)) {
// 동일 구간 → 가격 갱신
ta4jStorage.updateLastBar(market, type, partial.close, partial.volume);
} else if (lastUpperEndInstant.isBefore(upperEndInstant)) {
// 다음 구간 → 새 Bar 추가 (이전 상위봉 마감)
Bar upperBar = buildTa4jBar(partial, Duration.ofMinutes(minutes), upperEnd);
ta4jStorage.addBar(market, type, upperBar);
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);
}
}
}
}
}
/**
* 봉 마감 시점 전략 평가 + 시그널 저장 + 주문 큐 적재.
*
* @param publishStompSignal 1m 만 STOMP 시그널 필드에 실어 보냄
*/
private void evaluateStrategyOnBarClose(String market, String candleType, int maturedIndex,
PartialBar partial, Bar bar, boolean publishStompSignal) {
String closeSignal = liveStrategyEvaluator.evaluateCandleClose(market, candleType, maturedIndex);
String signalExecType = "CANDLE_CLOSE";
if ("NONE".equals(closeSignal)) {
closeSignal = liveStrategyEvaluator.evaluateRealtimeAtClose(market, candleType, maturedIndex);
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)) return;
try {
final String execType = signalExecType;
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();
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);
if (devId != null) {
orderExecutionQueue.submitSignal(devId, uid, market, stratId, closeSignal, partial.close);
}
} 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>
*/
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)
.open(p.open)
.high(p.high)
.low(p.low)
.close(barClose)
.volume(p.volume)
.signal(isRealSignal ? signal : null) // null 이면 @JsonInclude(NON_NULL) 로 직렬화 제외
.build();
broker.publish(market, "1m", dto);
publishUpperTimeframes(market);
}
/** 상위 분봉 구독자용 — Ta4jStorage 마지막 봉 스냅샷 (틱마다 1m 과 함께 발행) */
private void publishUpperTimeframes(String market) {
for (String type : UPPER_TYPES) {
try {
BarSeries series = ta4jStorage.getOrCreate(market, type);
if (series.isEmpty()) continue;
Bar last = series.getLastBar();
long barStart = last.getEndTime().getEpochSecond() - last.getTimePeriod().getSeconds();
broker.publish(market, type, CandleBarDto.builder()
.time(barStart)
.open(last.getOpenPrice().doubleValue())
.high(last.getHighPrice().doubleValue())
.low(last.getLowPrice().doubleValue())
.close(last.getClosePrice().doubleValue())
.volume(last.getVolume().doubleValue())
.build());
} catch (Exception e) {
log.trace("[BarBuilder] upper publish skip {}/{}: {}", market, type, e.getMessage());
}
}
}
/** PartialBar → Ta4j Bar 변환 */
private Bar buildTa4jBar(PartialBar partial, Duration duration, ZonedDateTime endZdt) {
BarSeries tmp = new org.ta4j.core.BaseBarSeriesBuilder()
.withBarBuilderFactory(new TimeBarBuilderFactory())
.withNumFactory(DoubleNumFactory.getInstance()) // Ta4j 0.22
.build();
// Ta4j 0.22: barBuilder().endTime() 은 Instant 파라미터
Instant endInstant = endZdt.toInstant();
return tmp.barBuilder()
.timePeriod(duration)
.endTime(endInstant)
.openPrice(partial.open)
.highPrice(partial.high)
.lowPrice(partial.low)
.closePrice(partial.close)
.volume(partial.volume)
.build();
}
/** 업비트 일봉 기준 시각: KST (UTC+9) */
private static final ZoneId ZONE_KST = ZoneId.of("Asia/Seoul");
/**
* 분 단위 시간을 상위 interval 의 시작으로 정렬.
*
* 일봉(minutes == 1440)은 업비트와 동일하게 KST 00:00 기준으로 정렬한다.
* 그 외 모든 타임프레임은 UTC epoch 기준 정렬(기존 방식 유지).
*/
private ZonedDateTime alignToInterval(ZonedDateTime time, int minutes) {
if (minutes == 1440) {
// 일봉: KST 자정(00:00) 기준 — 업비트 일봉 경계와 일치
ZonedDateTime kst = time.withZoneSameInstant(ZONE_KST);
return kst.toLocalDate().atStartOfDay(ZONE_KST);
}
long epochMin = time.toEpochSecond() / 60;
long aligned = (epochMin / minutes) * minutes;
return Instant.ofEpochSecond(aligned * 60).atZone(ZoneOffset.UTC);
}
// ── 내부 상태 ──────────────────────────────────────────────────────────────
/** 진행 중인 단일 캔들 상태 */
static class PartialBar {
final ZonedDateTime barStart;
final double open;
double high;
double low;
double close;
double volume;
PartialBar(ZonedDateTime barStart, double price, double volume) {
this.barStart = barStart;
this.open = price;
this.high = price;
this.low = price;
this.close = price;
this.volume = volume;
}
void update(double price, double vol) {
if (price > this.high) this.high = price;
if (price < this.low) this.low = price;
this.close = price;
this.volume += vol;
}
}
}