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,252 @@
package com.goldenchart.storage;
import com.goldenchart.trading.pipeline.TradingPipelineProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
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.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* 인메모리 실시간 시세 저장소 (싱글톤 서비스).
*
* 구조: ConcurrentHashMap<market, Map<candleType, BarSeries>>
* - market : "KRW-BTC", "KRW-ETH" 등 업비트 종목 코드
* - candleType: "1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"
*
* 슬라이딩 윈도우: 모든 BarSeries 에 setMaximumBarCount(300) 적용.
* 301번째 캔들이 들어오면 가장 오래된 캔들이 자동 소멸.
*/
@Service
@Slf4j
public class Ta4jStorage {
/** 인메모리 최대 캔들 보관 수 (기본 — 파이프라인 비활성 시) */
public static final int MAX_BAR_COUNT = 1800;
private final TradingPipelineProperties pipelineProperties;
public Ta4jStorage(TradingPipelineProperties pipelineProperties) {
this.pipelineProperties = pipelineProperties;
}
/** 외부 캔들 요청 1회당 기본 배치 크기 */
public static final int FETCH_BATCH = 200;
/** 지원 캔들 타입 → 업비트 API 경로 세그먼트 매핑 */
public static final Map<String, String> CANDLE_TYPE_MAP = Map.of(
"1m", "minutes/1",
"3m", "minutes/3",
"5m", "minutes/5",
"15m", "minutes/15",
"30m", "minutes/30",
"1h", "minutes/60",
"4h", "minutes/240",
"1d", "days"
);
// ── 인메모리 저장소 ───────────────────────────────────────────────────────
// 2중 ConcurrentHashMap: market → (candleType → BarSeries)
private final ConcurrentHashMap<String, ConcurrentHashMap<String, BarSeries>> storage =
new ConcurrentHashMap<>();
// ── BarSeries 접근 ────────────────────────────────────────────────────────
/**
* 종목·타입의 BarSeries 를 반환. 없으면 새로 생성한다.
*/
public BarSeries getOrCreate(String market, String candleType) {
return storage
.computeIfAbsent(market, k -> new ConcurrentHashMap<>())
.computeIfAbsent(candleType, k -> createSeries(market, candleType));
}
/** 종목·타입의 BarSeries 가 이미 존재하는지 확인 */
public boolean exists(String market, String candleType) {
Map<String, BarSeries> inner = storage.get(market);
return inner != null && inner.containsKey(candleType);
}
/** 종목·타입의 BarSeries 삭제 (GC 시 사용) */
public void remove(String market, String candleType) {
Map<String, BarSeries> inner = storage.get(market);
if (inner != null) {
inner.remove(candleType);
if (inner.isEmpty()) storage.remove(market);
log.info("[Ta4jStorage] 종목 제거: {} / {}", market, candleType);
}
}
// ── 캔들 추가 ─────────────────────────────────────────────────────────────
/**
* Bar 를 시리즈에 추가한다.
*
* <p>Ta4j {@code addBar(bar, replace=true)} 는 endTime 비교 없이 무조건 마지막 슬롯을
* 덮어쓴다. 이 동작은 진행 중 봉(provisional)을 추가할 때 방금 확정된 봉을 파괴하는
* 버그를 유발한다. 따라서 마지막 봉의 endTime 을 직접 비교해 교체 여부를 결정한다.
*
* <ul>
* <li>endTime 동일 → replace (진행 중 봉 → 확정봉으로 교체, 또는 warm-up 재추가)</li>
* <li>endTime 다름 → append (신규 봉 추가)</li>
* </ul>
*/
public synchronized void addBar(String market, String candleType, Bar bar) {
BarSeries series = getOrCreate(market, candleType);
try {
boolean sameEndTime = !series.isEmpty()
&& series.getLastBar().getEndTime().equals(bar.getEndTime());
series.addBar(bar, sameEndTime);
} catch (Exception e) {
log.warn("[Ta4jStorage] addBar 오류 ({}/{}): {}", market, candleType, e.getMessage());
}
}
/**
* 현재 진행 중인 Bar 의 가격·거래량 실시간 갱신. (틱 수신 시)
*/
public synchronized void updateLastBar(String market, String candleType,
double price, double volume) {
BarSeries series = getOrCreate(market, candleType);
if (series.isEmpty()) return;
try {
// Ta4j 0.22: addPrice(Num) — 종가 갱신 (고가/저가/거래량은 내부에서 자동 처리)
series.addPrice(DoubleNumFactory.getInstance().numOf(price));
} catch (Exception e) {
log.warn("[Ta4jStorage] updateLastBar 오류 ({}/{}): {}", market, candleType, e.getMessage());
}
}
// ── 조회 ──────────────────────────────────────────────────────────────────
/**
* 인메모리 캔들 전체를 시간순(오래된 순) 리스트로 반환.
*/
public List<Bar> getBars(String market, String candleType) {
if (!exists(market, candleType)) return Collections.emptyList();
BarSeries series = getOrCreate(market, candleType);
if (series.isEmpty()) return Collections.emptyList();
List<Bar> bars = new ArrayList<>();
for (int i = series.getBeginIndex(); i <= series.getEndIndex(); i++) {
bars.add(series.getBar(i));
}
return bars;
}
/** 인메모리에 실제 봉이 1개 이상 있는지 */
public boolean hasBars(String market, String candleType) {
return !getBars(market, candleType).isEmpty();
}
/**
* 인메모리에서 가장 오래된 캔들 시간 반환. 없으면 empty.
* Ta4j 0.22: Bar.getEndTime() 은 Instant 반환 → ZonedDateTime 변환
*/
public Optional<ZonedDateTime> getOldestTime(String market, String candleType) {
if (!exists(market, candleType)) return Optional.empty();
BarSeries series = getOrCreate(market, candleType);
if (series.isEmpty()) return Optional.empty();
return Optional.of(series.getFirstBar().getEndTime().atZone(ZoneOffset.UTC));
}
/**
* 인메모리에서 가장 최신 캔들 시간 반환. 없으면 empty.
*/
public Optional<ZonedDateTime> getLatestTime(String market, String candleType) {
if (!exists(market, candleType)) return Optional.empty();
BarSeries series = getOrCreate(market, candleType);
if (series.isEmpty()) return Optional.empty();
return Optional.of(series.getLastBar().getEndTime().atZone(ZoneOffset.UTC));
}
/**
* 인메모리 캔들 수 반환.
*/
public int getBarCount(String market, String candleType) {
if (!exists(market, candleType)) return 0;
return getOrCreate(market, candleType).getBarCount();
}
/**
* 현재 구독(메모리) 중인 종목 목록 반환.
*/
public List<String> getSubscribedMarkets() {
return new ArrayList<>(storage.keySet());
}
/** 대시보드·파이프라인 모니터링용 인메모리 저장소 통계 */
public Map<String, Object> storageMetrics() {
int markets = storage.size();
int series = 0;
int totalBars = 0;
for (var inner : storage.values()) {
series += inner.size();
for (BarSeries s : inner.values()) {
totalBars += s.getBarCount();
}
}
int maxBars = pipelineProperties.isEnabled()
? Math.max(200, pipelineProperties.getMaxBarsPerSeries())
: MAX_BAR_COUNT;
return Map.of(
"markets", markets,
"series", series,
"totalBars", totalBars,
"maxBarsPerSeries", maxBars
);
}
// ── 내부 유틸 ─────────────────────────────────────────────────────────────
private BarSeries createSeries(String market, String candleType) {
int maxBars = pipelineProperties.isEnabled()
? Math.max(200, pipelineProperties.getMaxBarsPerSeries())
: MAX_BAR_COUNT;
BarSeries series = new BaseBarSeriesBuilder()
.withName(market + ":" + candleType)
.withBarBuilderFactory(new TimeBarBuilderFactory())
.withNumFactory(DoubleNumFactory.getInstance()) // Ta4j 0.22: withNumFactory
.withMaxBarCount(maxBars)
.build();
log.info("[Ta4jStorage] BarSeries 생성: {} / {} (maxBarCount={})", market, candleType, maxBars);
return series;
}
/** 캔들 타입 문자열 → Duration 변환 (package→public) */
public static Duration parseDuration(String candleType) {
return switch (candleType) {
case "1m" -> Duration.ofMinutes(1);
case "3m" -> Duration.ofMinutes(3);
case "5m" -> Duration.ofMinutes(5);
case "15m" -> Duration.ofMinutes(15);
case "30m" -> Duration.ofMinutes(30);
case "1h" -> Duration.ofHours(1);
case "4h" -> Duration.ofHours(4);
case "1d" -> Duration.ofDays(1);
default -> Duration.ofMinutes(1);
};
}
/** Unix 초 타임스탬프 → ZonedDateTime (UTC) */
public static ZonedDateTime epochToZdt(long epochSeconds) {
return Instant.ofEpochSecond(epochSeconds).atZone(ZoneOffset.UTC);
}
/** Unix 초 타임스탬프 → Instant */
public static Instant epochToInstant(long epochSeconds) {
return Instant.ofEpochSecond(epochSeconds);
}
}