goldenChat base source add
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user