162 lines
6.2 KiB
Java
162 lines
6.2 KiB
Java
package com.goldenchart.trading.pipeline;
|
|
|
|
import com.fasterxml.jackson.core.type.TypeReference;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import com.goldenchart.storage.Ta4jStorage;
|
|
import com.goldenchart.websocket.UpbitWebSocketClient;
|
|
import lombok.RequiredArgsConstructor;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
import org.springframework.scheduling.annotation.Scheduled;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.web.reactive.function.client.WebClient;
|
|
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.Comparator;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
import java.util.concurrent.atomic.AtomicInteger;
|
|
import java.util.concurrent.atomic.AtomicLong;
|
|
|
|
/**
|
|
* 5분마다 REST 로 최근 캔들을 조회해 WS 갭을 멱등적으로 보정한다.
|
|
*/
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
@Slf4j
|
|
public class CandleGapBackfillService {
|
|
|
|
private final Ta4jStorage ta4jStorage;
|
|
private final UpbitWebSocketClient upbitWebSocketClient;
|
|
private final TradingPipelineProperties props;
|
|
private final WebClient.Builder webClientBuilder;
|
|
private final ObjectMapper objectMapper;
|
|
|
|
@Value("${upbit.api.base-url:https://api.upbit.com}")
|
|
private String upbitBaseUrl;
|
|
|
|
@Value("${upbit.api.request-delay-ms:110}")
|
|
private long requestDelayMs;
|
|
|
|
private static final String[] BACKFILL_TYPES = {"1m", "3m", "5m", "10m", "15m", "30m", "1h", "4h", "1d"};
|
|
|
|
private volatile long lastRunAtMs;
|
|
private final AtomicInteger lastRunMarkets = new AtomicInteger();
|
|
private final AtomicLong lastRunBarsMerged = new AtomicLong();
|
|
private volatile String lastRunError = "";
|
|
|
|
public Map<String, Object> monitorSnapshot() {
|
|
Map<String, Object> m = new LinkedHashMap<>();
|
|
m.put("cron", props.getGapBackfillCron());
|
|
m.put("candleCount", props.getGapBackfillCandleCount());
|
|
m.put("lastRunAtMs", lastRunAtMs);
|
|
m.put("lastRunMarkets", lastRunMarkets.get());
|
|
m.put("lastRunBarsMerged", lastRunBarsMerged.get());
|
|
m.put("lastRunError", lastRunError);
|
|
return m;
|
|
}
|
|
|
|
@Scheduled(cron = "${goldenchart.trading.pipeline.gap-backfill-cron:0 */5 * * * *}")
|
|
public void scheduledBackfill() {
|
|
if (!props.isEnabled()) return;
|
|
Set<String> markets = upbitWebSocketClient.getSubscribedMarkets();
|
|
if (markets.isEmpty()) return;
|
|
|
|
lastRunAtMs = System.currentTimeMillis();
|
|
lastRunMarkets.set(markets.size());
|
|
lastRunBarsMerged.set(0);
|
|
lastRunError = "";
|
|
|
|
int count = props.getGapBackfillCandleCount();
|
|
long mergedTotal = 0;
|
|
for (String market : markets) {
|
|
for (String type : BACKFILL_TYPES) {
|
|
if (!ta4jStorage.exists(market, type)) continue;
|
|
try {
|
|
Thread.sleep(requestDelayMs);
|
|
mergedTotal += backfillMarket(market, type, count);
|
|
} catch (InterruptedException e) {
|
|
Thread.currentThread().interrupt();
|
|
return;
|
|
} catch (Exception e) {
|
|
lastRunError = e.getMessage();
|
|
log.debug("[GapBackfill] {} {}: {}", market, type, e.getMessage());
|
|
}
|
|
}
|
|
}
|
|
lastRunBarsMerged.set(mergedTotal);
|
|
}
|
|
|
|
int backfillMarket(String market, String candleType, int count) throws Exception {
|
|
String path = Ta4jStorage.CANDLE_TYPE_MAP.getOrDefault(candleType, "minutes/1");
|
|
Duration duration = Ta4jStorage.parseDuration(candleType);
|
|
String url = upbitBaseUrl + "/v1/candles/" + path
|
|
+ "?market=" + market + "&count=" + count;
|
|
|
|
String json = webClientBuilder.build()
|
|
.get().uri(url)
|
|
.retrieve().bodyToMono(String.class).block();
|
|
if (json == null || json.isBlank()) return 0;
|
|
|
|
List<Map<String, Object>> page =
|
|
objectMapper.readValue(json, new TypeReference<>() {});
|
|
if (page.isEmpty()) return 0;
|
|
|
|
page.sort(Comparator.comparingLong(r ->
|
|
parseEpoch((String) r.get("candle_date_time_utc"))));
|
|
|
|
int merged = 0;
|
|
for (Map<String, Object> r : page) {
|
|
long epochSec = parseEpoch((String) r.get("candle_date_time_utc"));
|
|
Instant endInstant = Instant.ofEpochSecond(epochSec).plus(duration);
|
|
double open = toDouble(r.get("opening_price"));
|
|
double high = toDouble(r.get("high_price"));
|
|
double low = toDouble(r.get("low_price"));
|
|
double close = toDouble(r.get("trade_price"));
|
|
double vol = toDouble(r.get("candle_acc_trade_volume"));
|
|
|
|
Bar bar = new BaseBarSeriesBuilder()
|
|
.withBarBuilderFactory(new TimeBarBuilderFactory())
|
|
.withNumFactory(DoubleNumFactory.getInstance())
|
|
.build()
|
|
.barBuilder()
|
|
.timePeriod(duration)
|
|
.endTime(endInstant)
|
|
.openPrice(open).highPrice(high).lowPrice(low)
|
|
.closePrice(close).volume(vol)
|
|
.build();
|
|
|
|
ta4jStorage.addBar(market, candleType, bar);
|
|
merged++;
|
|
}
|
|
if (merged > 0) {
|
|
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
|
|
log.debug("[GapBackfill] {} {} — REST {}봉 병합 (series={})",
|
|
market, candleType, merged, series.getBarCount());
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
private static long parseEpoch(String utc) {
|
|
if (utc == null) return 0;
|
|
String s = utc.endsWith("Z") ? utc : utc + "Z";
|
|
return ZonedDateTime.parse(s).toEpochSecond();
|
|
}
|
|
|
|
private static double toDouble(Object o) {
|
|
if (o == null) return 0;
|
|
if (o instanceof Number n) return n.doubleValue();
|
|
return Double.parseDouble(o.toString());
|
|
}
|
|
}
|