goldenChat base source add
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
package com.goldenchart.trading;
|
||||
|
||||
import com.goldenchart.entity.GcAppSettings;
|
||||
import com.goldenchart.service.AppSettingsService;
|
||||
import com.goldenchart.service.LiveTradingService;
|
||||
import com.goldenchart.service.PaperTradingService;
|
||||
import com.goldenchart.trading.pipeline.OrderRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 운영 모드(PAPER / LIVE / BOTH)에 따라 모의·실거래 실행을 분기한다.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TradingExecutionService {
|
||||
|
||||
private final AppSettingsService appSettingsService;
|
||||
private final PaperTradingService paperTradingService;
|
||||
private final LiveTradingService liveTradingService;
|
||||
|
||||
public void executeSignal(String deviceId, Long userId, String market,
|
||||
Long strategyId, String side, double price) {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
TradingMode mode = TradingMode.fromString(app.getTradingMode());
|
||||
|
||||
if (mode.usePaper() && Boolean.TRUE.equals(app.getPaperTradingEnabled())) {
|
||||
paperTradingService.tryExecuteOnSignal(deviceId, userId, market, strategyId, side, price);
|
||||
}
|
||||
if (mode.useLive()) {
|
||||
liveTradingService.tryExecuteOnSignal(
|
||||
deviceId, userId, market, strategyId, side, price, "STRATEGY");
|
||||
}
|
||||
}
|
||||
|
||||
public void executeRiskExit(OrderRequest req) {
|
||||
GcAppSettings app = appSettingsService.getEntity(req.userId(), req.deviceId());
|
||||
TradingMode mode = TradingMode.fromString(app.getTradingMode());
|
||||
String source = req.kind() == OrderRequest.OrderKind.STOP_LOSS ? "STOP_LOSS" : "TAKE_PROFIT";
|
||||
|
||||
if (mode.usePaper() && Boolean.TRUE.equals(app.getPaperTradingEnabled())) {
|
||||
paperTradingService.tryExecuteOnSignal(
|
||||
req.deviceId(), req.userId(), req.market(), null, "SELL", req.price());
|
||||
}
|
||||
if (mode.useLive()) {
|
||||
liveTradingService.tryExecuteOnSignal(
|
||||
req.deviceId(), req.userId(), req.market(), null, "SELL", req.price(), source);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.goldenchart.trading;
|
||||
|
||||
/**
|
||||
* 자동매매 실행 대상: 모의 / 실거래 / 병행.
|
||||
*/
|
||||
public enum TradingMode {
|
||||
PAPER,
|
||||
LIVE,
|
||||
BOTH;
|
||||
|
||||
public static TradingMode fromString(String raw) {
|
||||
if (raw == null || raw.isBlank()) return PAPER;
|
||||
try {
|
||||
return valueOf(raw.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return PAPER;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean usePaper() {
|
||||
return this == PAPER || this == BOTH;
|
||||
}
|
||||
|
||||
public boolean useLive() {
|
||||
return this == LIVE || this == BOTH;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
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", "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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.goldenchart.trading.pipeline;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 고정 크기 워커 풀이 이벤트 큐에서 틱을 꺼내 종목별 연산을 수행한다.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CoreEngineWorkerPool {
|
||||
|
||||
private final TradeEventQueue eventQueue;
|
||||
private final MarketTickProcessor tickProcessor;
|
||||
private final TradingPipelineProperties props;
|
||||
|
||||
private final AtomicLong processed = new AtomicLong();
|
||||
private final AtomicLong errors = new AtomicLong();
|
||||
|
||||
private ExecutorService executor;
|
||||
private volatile boolean running;
|
||||
|
||||
@PostConstruct
|
||||
void start() {
|
||||
if (!props.isEnabled()) {
|
||||
log.info("[CoreEngineWorkerPool] 비활성 — 동기 BarBuilder 경로 사용");
|
||||
return;
|
||||
}
|
||||
int n = Math.max(2, Math.min(16, props.getWorkerThreads()));
|
||||
running = true;
|
||||
executor = Executors.newFixedThreadPool(n, r -> {
|
||||
Thread t = new Thread(r, "core-engine-worker");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
for (int i = 0; i < n; i++) {
|
||||
executor.submit(this::workerLoop);
|
||||
}
|
||||
log.info("[CoreEngineWorkerPool] {} 워커 시작", n);
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void shutdown() {
|
||||
running = false;
|
||||
if (executor != null) {
|
||||
executor.shutdownNow();
|
||||
try {
|
||||
executor.awaitTermination(5, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void workerLoop() {
|
||||
while (running) {
|
||||
try {
|
||||
TradeTickEvent event = eventQueue.take();
|
||||
tickProcessor.process(event);
|
||||
processed.incrementAndGet();
|
||||
} catch (InterruptedException e) {
|
||||
if (!running) break;
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
errors.incrementAndGet();
|
||||
log.warn("[CoreEngineWorkerPool] 틱 처리 오류: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return running && executor != null && !executor.isShutdown();
|
||||
}
|
||||
|
||||
public int configuredWorkers() {
|
||||
return Math.max(2, Math.min(16, props.getWorkerThreads()));
|
||||
}
|
||||
|
||||
public WorkerStats stats() {
|
||||
return new WorkerStats(processed.get(), errors.get(), isActive(), configuredWorkers());
|
||||
}
|
||||
|
||||
public record WorkerStats(long processed, long errors, boolean active, int workerThreads) {}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.goldenchart.trading.pipeline;
|
||||
|
||||
import com.goldenchart.service.LiveRiskMonitorService;
|
||||
import com.goldenchart.websocket.BarBuilder;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Core Engine — 단일 틱에 대한 캔들 조립 + 실시간 리스크 감시.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MarketTickProcessor {
|
||||
|
||||
private final BarBuilder barBuilder;
|
||||
private final RealtimePositionMonitor positionMonitor;
|
||||
private final LiveRiskMonitorService liveRiskMonitor;
|
||||
private final TickActivityTracker activityTracker;
|
||||
|
||||
public void process(TradeTickEvent event) {
|
||||
activityTracker.record(event.market());
|
||||
barBuilder.onTick(event.market(), event.tradePrice(), event.tradeVolume(), event.tradeTimeMs());
|
||||
positionMonitor.onTick(event.market(), event.tradePrice());
|
||||
liveRiskMonitor.onTick(event.market(), event.tradePrice());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.goldenchart.trading.pipeline;
|
||||
|
||||
import com.goldenchart.trading.TradingExecutionService;
|
||||
import com.google.common.util.concurrent.RateLimiter;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 주문 전용 단일 쓰레드 + RateLimiter — 업비트 429 방지.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class OrderExecutionQueue {
|
||||
|
||||
private final TradingExecutionService tradingExecutionService;
|
||||
private final TradingPipelineProperties props;
|
||||
|
||||
private final BlockingQueue<OrderRequest> queue = new LinkedBlockingQueue<>(4096);
|
||||
private final AtomicLong submitted = new AtomicLong();
|
||||
private final AtomicLong executed = new AtomicLong();
|
||||
private final AtomicLong failed = new AtomicLong();
|
||||
private final AtomicLong dropped = new AtomicLong();
|
||||
|
||||
private RateLimiter rateLimiter;
|
||||
private volatile boolean running;
|
||||
private Thread worker;
|
||||
|
||||
@PostConstruct
|
||||
void start() {
|
||||
if (!props.isEnabled()) return;
|
||||
rateLimiter = RateLimiter.create(Math.max(0.5, props.getOrderRatePerSecond()));
|
||||
running = true;
|
||||
worker = new Thread(this::runLoop, "order-execution");
|
||||
worker.setDaemon(true);
|
||||
worker.start();
|
||||
log.info("[OrderExecutionQueue] 시작 (rate={}/s)", props.getOrderRatePerSecond());
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void stop() {
|
||||
running = false;
|
||||
if (worker != null) worker.interrupt();
|
||||
}
|
||||
|
||||
public void submit(OrderRequest req) {
|
||||
if (!props.isEnabled()) {
|
||||
executeNow(req);
|
||||
return;
|
||||
}
|
||||
if (!queue.offer(req)) {
|
||||
dropped.incrementAndGet();
|
||||
log.warn("[OrderExecutionQueue] 주문 큐 포화 — {} {}", req.market(), req.kind());
|
||||
} else {
|
||||
submitted.incrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
public void submitSignal(String deviceId, Long userId, String market,
|
||||
Long strategyId, String signal, double price) {
|
||||
submit(OrderRequest.strategySignal(deviceId, userId, market, strategyId, signal, price));
|
||||
}
|
||||
|
||||
private void runLoop() {
|
||||
while (running) {
|
||||
try {
|
||||
OrderRequest req = queue.poll(1, TimeUnit.SECONDS);
|
||||
if (req == null) continue;
|
||||
rateLimiter.acquire();
|
||||
executeNow(req);
|
||||
executed.incrementAndGet();
|
||||
} catch (InterruptedException e) {
|
||||
if (!running) break;
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
failed.incrementAndGet();
|
||||
log.warn("[OrderExecutionQueue] 실행 실패: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void executeNow(OrderRequest req) {
|
||||
if (req.deviceId() == null || req.deviceId().isBlank()) return;
|
||||
if (!"BUY".equals(req.side()) && !"SELL".equals(req.side())) return;
|
||||
try {
|
||||
if (req.kind() == OrderRequest.OrderKind.STRATEGY_SIGNAL) {
|
||||
tradingExecutionService.executeSignal(
|
||||
req.deviceId(), req.userId(), req.market(),
|
||||
req.strategyId(), req.side(), req.price());
|
||||
} else {
|
||||
tradingExecutionService.executeRiskExit(req);
|
||||
}
|
||||
log.debug("[OrderExecutionQueue] {} {} {} ({})",
|
||||
req.kind(), req.side(), req.market(), req.reason());
|
||||
} catch (Exception e) {
|
||||
log.warn("[OrderExecutionQueue] {} {}: {}", req.kind(), req.market(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isWorkerAlive() {
|
||||
return worker != null && worker.isAlive();
|
||||
}
|
||||
|
||||
public OrderQueueStats stats() {
|
||||
return new OrderQueueStats(
|
||||
queue.size(),
|
||||
submitted.get(),
|
||||
executed.get(),
|
||||
failed.get(),
|
||||
dropped.get(),
|
||||
isWorkerAlive(),
|
||||
props.isEnabled() ? props.getOrderRatePerSecond() : 0
|
||||
);
|
||||
}
|
||||
|
||||
public record OrderQueueStats(
|
||||
int pending, long submitted, long executed, long failed, long dropped,
|
||||
boolean workerAlive, double ratePerSecond) {}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.goldenchart.trading.pipeline;
|
||||
|
||||
/**
|
||||
* 주문 실행 큐에 적재되는 모의투자 주문 요청.
|
||||
*/
|
||||
public record OrderRequest(
|
||||
OrderKind kind,
|
||||
String deviceId,
|
||||
Long userId,
|
||||
String market,
|
||||
Long strategyId,
|
||||
String side,
|
||||
double price,
|
||||
String reason
|
||||
) {
|
||||
public enum OrderKind {
|
||||
STRATEGY_SIGNAL,
|
||||
STOP_LOSS,
|
||||
TAKE_PROFIT
|
||||
}
|
||||
|
||||
public static OrderRequest strategySignal(String deviceId, Long userId, String market,
|
||||
Long strategyId, String side, double price) {
|
||||
return new OrderRequest(OrderKind.STRATEGY_SIGNAL, deviceId, userId, market,
|
||||
strategyId, side, price, "STRATEGY");
|
||||
}
|
||||
|
||||
public static OrderRequest stopLoss(String deviceId, Long userId, String market,
|
||||
double price, double lossPct) {
|
||||
return new OrderRequest(OrderKind.STOP_LOSS, deviceId, userId, market,
|
||||
null, "SELL", price, "STOP_LOSS_" + lossPct + "%");
|
||||
}
|
||||
|
||||
public static OrderRequest takeProfit(String deviceId, Long userId, String market,
|
||||
double price, double profitPct) {
|
||||
return new OrderRequest(OrderKind.TAKE_PROFIT, deviceId, userId, market,
|
||||
null, "SELL", price, "TAKE_PROFIT_" + profitPct + "%");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package com.goldenchart.trading.pipeline;
|
||||
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
import com.goldenchart.websocket.UpbitWebSocketClient;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.lang.management.MemoryMXBean;
|
||||
import java.lang.management.ThreadInfo;
|
||||
import java.lang.management.ThreadMXBean;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 멀티스레드 파이프라인 운영 모니터링 (대시보드·/trading/pipeline/status).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PipelineMonitorService {
|
||||
|
||||
private static final String[] MONITORED_THREAD_PREFIXES = {
|
||||
"core-engine-worker",
|
||||
"order-execution",
|
||||
"upbit-ws-reconnect",
|
||||
"ws-staleness-monitor",
|
||||
"warmup-thread",
|
||||
"WebSocketConnectReadThread",
|
||||
};
|
||||
|
||||
private final TradingPipelineProperties props;
|
||||
private final TradeEventQueue eventQueue;
|
||||
private final CoreEngineWorkerPool workerPool;
|
||||
private final OrderExecutionQueue orderQueue;
|
||||
private final TickActivityTracker activityTracker;
|
||||
private final UpbitWebSocketClient upbitWebSocketClient;
|
||||
private final WebSocketStalenessMonitor stalenessMonitor;
|
||||
private final CandleGapBackfillService gapBackfillService;
|
||||
private final Ta4jStorage ta4jStorage;
|
||||
|
||||
private long prevSampleMs;
|
||||
private long prevEnqueued;
|
||||
private long prevDequeued;
|
||||
private long prevProcessed;
|
||||
private long prevWsMessages;
|
||||
private long prevOrdersExecuted;
|
||||
|
||||
public Map<String, Object> buildMonitorSnapshot() {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
boolean enabled = props.isEnabled();
|
||||
out.put("pipelineEnabled", enabled);
|
||||
out.put("sampledAtMs", System.currentTimeMillis());
|
||||
out.put("config", Map.of(
|
||||
"workerThreads", props.getWorkerThreads(),
|
||||
"queueCapacity", props.getQueueCapacity(),
|
||||
"orderRatePerSecond", props.getOrderRatePerSecond(),
|
||||
"maxBarsPerSeries", props.getMaxBarsPerSeries(),
|
||||
"staleTickSeconds", props.getStaleTickSeconds(),
|
||||
"gapBackfillCron", props.getGapBackfillCron()
|
||||
));
|
||||
out.put("jvm", jvmMetrics());
|
||||
out.put("traffic", computeTrafficRates());
|
||||
out.put("memory", ta4jStorage.storageMetrics());
|
||||
out.put("queue", eventQueue.stats());
|
||||
out.put("workers", workerPool.stats());
|
||||
out.put("orders", orderQueue.stats());
|
||||
out.put("lastTicks", activityTracker.snapshot());
|
||||
out.put("staleMarkets", findStaleMarkets());
|
||||
out.put("threads", listMonitoredThreads());
|
||||
out.put("processes", buildProcessList(enabled));
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String, Object> jvmMetrics() {
|
||||
MemoryMXBean mem = ManagementFactory.getMemoryMXBean();
|
||||
long heapUsed = mem.getHeapMemoryUsage().getUsed();
|
||||
long heapMax = mem.getHeapMemoryUsage().getMax();
|
||||
long nonHeap = mem.getNonHeapMemoryUsage().getUsed();
|
||||
ThreadMXBean threads = ManagementFactory.getThreadMXBean();
|
||||
double heapPct = heapMax > 0 ? (heapUsed * 100.0 / heapMax) : 0;
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("heapUsedMb", roundMb(heapUsed));
|
||||
m.put("heapMaxMb", roundMb(heapMax));
|
||||
m.put("heapPct", Math.round(heapPct * 10) / 10.0);
|
||||
m.put("nonHeapUsedMb", roundMb(nonHeap));
|
||||
m.put("threadCount", threads.getThreadCount());
|
||||
m.put("daemonThreadCount", threads.getDaemonThreadCount());
|
||||
return m;
|
||||
}
|
||||
|
||||
private Map<String, Object> computeTrafficRates() {
|
||||
long now = System.currentTimeMillis();
|
||||
var q = eventQueue.stats();
|
||||
var w = workerPool.stats();
|
||||
var o = orderQueue.stats();
|
||||
var ws = upbitWebSocketClient.monitorSnapshot();
|
||||
long wsMsg = ((Number) ws.get("messagesReceived")).longValue();
|
||||
|
||||
Map<String, Object> rates = new LinkedHashMap<>();
|
||||
if (prevSampleMs > 0) {
|
||||
double sec = Math.max(0.5, (now - prevSampleMs) / 1000.0);
|
||||
rates.put("ticksEnqueuedPerSec", round2((q.enqueued() - prevEnqueued) / sec));
|
||||
rates.put("ticksDequeuedPerSec", round2((q.dequeued() - prevDequeued) / sec));
|
||||
rates.put("ticksProcessedPerSec", round2((w.processed() - prevProcessed) / sec));
|
||||
rates.put("wsMessagesPerSec", round2((wsMsg - prevWsMessages) / sec));
|
||||
rates.put("ordersExecutedPerSec", round2((o.executed() - prevOrdersExecuted) / sec));
|
||||
} else {
|
||||
rates.put("ticksEnqueuedPerSec", 0.0);
|
||||
rates.put("ticksDequeuedPerSec", 0.0);
|
||||
rates.put("ticksProcessedPerSec", 0.0);
|
||||
rates.put("wsMessagesPerSec", 0.0);
|
||||
rates.put("ordersExecutedPerSec", 0.0);
|
||||
}
|
||||
|
||||
prevSampleMs = now;
|
||||
prevEnqueued = q.enqueued();
|
||||
prevDequeued = q.dequeued();
|
||||
prevProcessed = w.processed();
|
||||
prevWsMessages = wsMsg;
|
||||
prevOrdersExecuted = o.executed();
|
||||
return rates;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> findStaleMarkets() {
|
||||
long now = System.currentTimeMillis();
|
||||
List<Map<String, Object>> stale = new ArrayList<>();
|
||||
for (String market : upbitWebSocketClient.getSubscribedMarkets()) {
|
||||
long last = activityTracker.lastTickMs(market);
|
||||
if (last == 0) continue;
|
||||
long ageSec = (now - last) / 1000;
|
||||
if (ageSec > props.getStaleTickSeconds()) {
|
||||
stale.add(Map.of("market", market, "lastTickAgeSec", ageSec));
|
||||
}
|
||||
}
|
||||
stale.sort((a, b) -> Long.compare(
|
||||
((Number) b.get("lastTickAgeSec")).longValue(),
|
||||
((Number) a.get("lastTickAgeSec")).longValue()));
|
||||
return stale;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> listMonitoredThreads() {
|
||||
ThreadMXBean mx = ManagementFactory.getThreadMXBean();
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
for (ThreadInfo info : mx.dumpAllThreads(false, false)) {
|
||||
if (info == null) continue;
|
||||
String name = info.getThreadName();
|
||||
boolean match = false;
|
||||
for (String prefix : MONITORED_THREAD_PREFIXES) {
|
||||
if (name.contains(prefix)) {
|
||||
match = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!match) continue;
|
||||
list.add(Map.of(
|
||||
"name", name,
|
||||
"id", info.getThreadId(),
|
||||
"state", info.getThreadState().name(),
|
||||
"daemon", info.isDaemon()
|
||||
));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildProcessList(boolean pipelineEnabled) {
|
||||
List<Map<String, Object>> processes = new ArrayList<>();
|
||||
var ws = upbitWebSocketClient.monitorSnapshot();
|
||||
var q = eventQueue.stats();
|
||||
var w = workerPool.stats();
|
||||
var o = orderQueue.stats();
|
||||
|
||||
processes.add(process("ingestion", "Upbit WebSocket (Ingestion)", "Ingestion",
|
||||
pipelineEnabled
|
||||
? (Boolean.TRUE.equals(ws.get("connected")) ? "healthy" : "down")
|
||||
: (Boolean.TRUE.equals(ws.get("connected")) ? "healthy" : "down"),
|
||||
Map.of(
|
||||
"connected", ws.get("connected"),
|
||||
"subscribedMarkets", ws.get("subscribedMarkets"),
|
||||
"messagesReceived", ws.get("messagesReceived"),
|
||||
"parseErrors", ws.get("parseErrors"),
|
||||
"reconnectDelaySec", ws.get("reconnectDelaySec")
|
||||
)));
|
||||
|
||||
String queueStatus = !pipelineEnabled ? "disabled"
|
||||
: q.fillRatio() > 0.85 ? "degraded"
|
||||
: q.dropped() > 0 ? "degraded"
|
||||
: "healthy";
|
||||
processes.add(process("eventBuffer", "Trade Event Queue", "Event Buffer", queueStatus,
|
||||
Map.of(
|
||||
"pending", q.pending(),
|
||||
"capacity", q.capacity(),
|
||||
"fillPct", Math.round(q.fillRatio() * 1000) / 10.0,
|
||||
"enqueued", q.enqueued(),
|
||||
"dequeued", q.dequeued(),
|
||||
"dropped", q.dropped()
|
||||
)));
|
||||
|
||||
String workerStatus = !pipelineEnabled ? "disabled"
|
||||
: !w.active() ? "down"
|
||||
: w.errors() > 0 ? "degraded"
|
||||
: "healthy";
|
||||
processes.add(process("coreEngine", "Core Engine Worker Pool", "Core Engine", workerStatus,
|
||||
Map.of(
|
||||
"active", w.active(),
|
||||
"workerThreads", w.workerThreads(),
|
||||
"processed", w.processed(),
|
||||
"errors", w.errors()
|
||||
)));
|
||||
|
||||
String orderStatus = !pipelineEnabled ? "disabled"
|
||||
: !o.workerAlive() ? "down"
|
||||
: o.pending() > 100 ? "degraded"
|
||||
: "healthy";
|
||||
processes.add(process("orderExecution", "Order Execution", "Execution", orderStatus,
|
||||
Map.of(
|
||||
"workerAlive", o.workerAlive(),
|
||||
"pending", o.pending(),
|
||||
"submitted", o.submitted(),
|
||||
"executed", o.executed(),
|
||||
"failed", o.failed(),
|
||||
"dropped", o.dropped(),
|
||||
"ratePerSecond", o.ratePerSecond()
|
||||
)));
|
||||
|
||||
var stale = stalenessMonitor.monitorSnapshot();
|
||||
processes.add(process("stalenessMonitor", "WS Staleness Monitor", "Health",
|
||||
pipelineEnabled
|
||||
? (Boolean.TRUE.equals(stale.get("running")) ? "healthy" : "down")
|
||||
: "disabled",
|
||||
stale));
|
||||
|
||||
processes.add(process("gapBackfill", "Candle Gap Backfill", "Maintenance",
|
||||
pipelineEnabled ? "healthy" : "disabled",
|
||||
gapBackfillService.monitorSnapshot()));
|
||||
|
||||
processes.add(process("ta4jStorage", "Ta4j In-Memory Store", "Storage",
|
||||
"healthy", ta4jStorage.storageMetrics()));
|
||||
|
||||
return processes;
|
||||
}
|
||||
|
||||
private static Map<String, Object> process(
|
||||
String id, String name, String layer, String status, Map<String, ?> metrics) {
|
||||
Map<String, Object> p = new LinkedHashMap<>();
|
||||
p.put("id", id);
|
||||
p.put("name", name);
|
||||
p.put("layer", layer);
|
||||
p.put("status", status);
|
||||
p.put("metrics", new HashMap<>(metrics));
|
||||
return p;
|
||||
}
|
||||
|
||||
private static double roundMb(long bytes) {
|
||||
return Math.round(bytes / 1024.0 / 1024.0 * 10) / 10.0;
|
||||
}
|
||||
|
||||
private static double round2(double v) {
|
||||
return Math.round(v * 100) / 100.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.goldenchart.trading.pipeline;
|
||||
|
||||
import com.goldenchart.dto.BacktestSettingsDto;
|
||||
import com.goldenchart.entity.GcAppSettings;
|
||||
import com.goldenchart.entity.GcPaperAccount;
|
||||
import com.goldenchart.entity.GcPaperPosition;
|
||||
import com.goldenchart.repository.GcPaperAccountRepository;
|
||||
import com.goldenchart.repository.GcPaperPositionRepository;
|
||||
import com.goldenchart.service.AppSettingsService;
|
||||
import com.goldenchart.service.BacktestSettingsService;
|
||||
import com.goldenchart.trading.TradingExecutionService;
|
||||
import com.goldenchart.trading.TradingMode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 봉 마감을 기다리지 않고 틱 단위로 손절/익절을 검사한다.
|
||||
* 백테스트 설정의 SL/TP 비율을 모의투자 실시간 감시에 재사용한다.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class RealtimePositionMonitor {
|
||||
|
||||
private final GcPaperPositionRepository positionRepo;
|
||||
private final GcPaperAccountRepository accountRepo;
|
||||
private final BacktestSettingsService backtestSettingsService;
|
||||
private final AppSettingsService appSettingsService;
|
||||
private final TradingExecutionService tradingExecutionService;
|
||||
private final TradingPipelineProperties props;
|
||||
|
||||
/** 동일 종목·계정에 중복 청산 주문 방지 (ms) */
|
||||
private final Map<String, Long> lastExitMs = new ConcurrentHashMap<>();
|
||||
private static final long EXIT_COOLDOWN_MS = 3_000;
|
||||
|
||||
public void onTick(String market, double tradePrice) {
|
||||
if (!props.isEnabled() || tradePrice <= 0) return;
|
||||
|
||||
List<GcPaperPosition> positions =
|
||||
positionRepo.findBySymbolAndQuantityGreaterThan(market, BigDecimal.ZERO);
|
||||
if (positions.isEmpty()) return;
|
||||
|
||||
for (GcPaperPosition pos : positions) {
|
||||
double qty = pos.getQuantity().doubleValue();
|
||||
if (qty <= 0) continue;
|
||||
|
||||
GcPaperAccount account = accountRepo.findById(pos.getAccountId()).orElse(null);
|
||||
if (account == null) continue;
|
||||
|
||||
String deviceId = account.getDeviceId();
|
||||
Long userId = account.getUserId();
|
||||
if (deviceId == null || deviceId.isBlank()) continue;
|
||||
|
||||
GcAppSettings app;
|
||||
try {
|
||||
app = appSettingsService.getEntity(userId, deviceId);
|
||||
} catch (Exception e) {
|
||||
continue;
|
||||
}
|
||||
TradingMode mode = TradingMode.fromString(app.getTradingMode());
|
||||
boolean paperPos = mode.usePaper() && Boolean.TRUE.equals(app.getPaperTradingEnabled());
|
||||
if (!paperPos && !mode.useLive()) continue;
|
||||
|
||||
String cooldownKey = account.getId() + ":" + market;
|
||||
long now = System.currentTimeMillis();
|
||||
Long last = lastExitMs.get(cooldownKey);
|
||||
if (last != null && now - last < EXIT_COOLDOWN_MS) continue;
|
||||
|
||||
double avg = pos.getAvgPrice().doubleValue();
|
||||
if (avg <= 0) continue;
|
||||
|
||||
BacktestSettingsDto risk = backtestSettingsService.get(deviceId);
|
||||
double pnlPct = (tradePrice - avg) / avg * 100.0;
|
||||
|
||||
if (Boolean.TRUE.equals(risk.getStopLossEnabled())
|
||||
&& risk.getStopLossPct() != null
|
||||
&& pnlPct <= -risk.getStopLossPct().doubleValue()) {
|
||||
lastExitMs.put(cooldownKey, now);
|
||||
double sl = risk.getStopLossPct().doubleValue();
|
||||
tradingExecutionService.executeRiskExit(
|
||||
OrderRequest.stopLoss(deviceId, userId, market, tradePrice, sl));
|
||||
log.info("[SL] {} @ {} pnl={}% (limit -{}%)", market, tradePrice,
|
||||
String.format("%.2f", pnlPct), sl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Boolean.TRUE.equals(risk.getTakeProfitEnabled())
|
||||
&& risk.getTakeProfitPct() != null
|
||||
&& pnlPct >= risk.getTakeProfitPct().doubleValue()) {
|
||||
lastExitMs.put(cooldownKey, now);
|
||||
double tp = risk.getTakeProfitPct().doubleValue();
|
||||
tradingExecutionService.executeRiskExit(
|
||||
OrderRequest.takeProfit(deviceId, userId, market, tradePrice, tp));
|
||||
log.info("[TP] {} @ {} pnl={}% (target +{}%)", market, tradePrice,
|
||||
String.format("%.2f", pnlPct), tp);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.goldenchart.trading.pipeline;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 종목별 마지막 체결 수신 시각 — WebSocket 헬스 모니터용.
|
||||
*/
|
||||
@Component
|
||||
public class TickActivityTracker {
|
||||
|
||||
private final Map<String, Long> lastTickMs = new ConcurrentHashMap<>();
|
||||
|
||||
public void record(String market) {
|
||||
lastTickMs.put(market, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
public long lastTickMs(String market) {
|
||||
return lastTickMs.getOrDefault(market, 0L);
|
||||
}
|
||||
|
||||
public Map<String, Long> snapshot() {
|
||||
return Map.copyOf(lastTickMs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.goldenchart.trading.pipeline;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* Ingestion → Core Engine 사이 고속 버퍼.
|
||||
* 수신 쓰레드는 offer 만 수행하고 즉시 반환한다.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class TradeEventQueue {
|
||||
|
||||
private final LinkedBlockingQueue<TradeTickEvent> queue;
|
||||
private final AtomicLong enqueued = new AtomicLong();
|
||||
private final AtomicLong dequeued = new AtomicLong();
|
||||
private final AtomicLong dropped = new AtomicLong();
|
||||
|
||||
public TradeEventQueue(TradingPipelineProperties props) {
|
||||
this.queue = new LinkedBlockingQueue<>(Math.max(1024, props.getQueueCapacity()));
|
||||
}
|
||||
|
||||
/** 수신 쓰레드 전용 — 블로킹 없음 */
|
||||
public boolean offer(TradeTickEvent event) {
|
||||
boolean ok = queue.offer(event);
|
||||
if (ok) {
|
||||
enqueued.incrementAndGet();
|
||||
} else {
|
||||
dropped.incrementAndGet();
|
||||
if (dropped.get() % 1000 == 1) {
|
||||
log.warn("[TradeEventQueue] 큐 포화 — 틱 드롭 누적={}", dropped.get());
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
/** 워커 쓰레드 — 블로킹 take */
|
||||
public TradeTickEvent take() throws InterruptedException {
|
||||
TradeTickEvent e = queue.take();
|
||||
dequeued.incrementAndGet();
|
||||
return e;
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return queue.size();
|
||||
}
|
||||
|
||||
public int capacity() {
|
||||
return queue.remainingCapacity() + queue.size();
|
||||
}
|
||||
|
||||
public PipelineQueueStats stats() {
|
||||
int cap = capacity();
|
||||
return new PipelineQueueStats(
|
||||
queue.size(),
|
||||
cap,
|
||||
cap > 0 ? (double) queue.size() / cap : 0,
|
||||
enqueued.get(),
|
||||
dequeued.get(),
|
||||
dropped.get()
|
||||
);
|
||||
}
|
||||
|
||||
public record PipelineQueueStats(
|
||||
int pending, int capacity, double fillRatio,
|
||||
long enqueued, long dequeued, long dropped) {}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.goldenchart.trading.pipeline;
|
||||
|
||||
/**
|
||||
* WebSocket 수신 쓰레드 → 이벤트 큐로 전달하는 불변 체결 틱.
|
||||
*/
|
||||
public record TradeTickEvent(
|
||||
String market,
|
||||
double tradePrice,
|
||||
double tradeVolume,
|
||||
long tradeTimeMs,
|
||||
long enqueuedAtMs
|
||||
) {
|
||||
public static TradeTickEvent of(String market, double price, double volume, long timeMs) {
|
||||
return new TradeTickEvent(market, price, volume, timeMs, System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.goldenchart.trading.pipeline;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 하이브리드 자동매매 파이프라인 설정 (multi_thread_base_archiitecture.md).
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "goldenchart.trading.pipeline")
|
||||
@Getter
|
||||
@Setter
|
||||
public class TradingPipelineProperties {
|
||||
|
||||
/** 이벤트 큐 기반 비동기 파이프라인 사용 */
|
||||
private boolean enabled = true;
|
||||
|
||||
/** 체결 틱 이벤트 큐 용량 */
|
||||
private int queueCapacity = 65_536;
|
||||
|
||||
/** Core Engine 워커 스레드 수 (4~8 권장) */
|
||||
private int workerThreads = 6;
|
||||
|
||||
/** 주문 REST 호출 초당 상한 (업비트 429 방지) */
|
||||
private double orderRatePerSecond = 5.0;
|
||||
|
||||
/** BarSeries 메모리 상한 (OOM 방지) */
|
||||
private int maxBarsPerSeries = 500;
|
||||
|
||||
/** 종목별 틱 미수신 시 재연결 요청(초) */
|
||||
private int staleTickSeconds = 30;
|
||||
|
||||
/** 갭 백필 스케줄 (Spring cron) */
|
||||
private String gapBackfillCron = "0 */5 * * * *";
|
||||
|
||||
/** 갭 백필 시 REST로 가져올 캔들 수 */
|
||||
private int gapBackfillCandleCount = 20;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.goldenchart.trading.pipeline;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 파이프라인 운영 상태 조회 (모니터링·디버깅).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/trading/pipeline")
|
||||
@RequiredArgsConstructor
|
||||
public class TradingPipelineStatusController {
|
||||
|
||||
private final PipelineMonitorService monitorService;
|
||||
|
||||
@GetMapping("/status")
|
||||
public Map<String, Object> status() {
|
||||
return monitorService.buildMonitorSnapshot();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.goldenchart.trading.pipeline;
|
||||
|
||||
import com.goldenchart.websocket.UpbitWebSocketClient;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 30초 이상 체결이 없는 구독 종목에 대해 WebSocket 재연결을 요청한다.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class WebSocketStalenessMonitor {
|
||||
|
||||
private final TradingPipelineProperties props;
|
||||
private final TickActivityTracker activityTracker;
|
||||
private final UpbitWebSocketClient upbitWebSocketClient;
|
||||
|
||||
private ScheduledExecutorService scheduler;
|
||||
private volatile boolean running;
|
||||
private volatile long lastCheckAtMs;
|
||||
private volatile int lastStaleCount;
|
||||
|
||||
@PostConstruct
|
||||
void start() {
|
||||
if (!props.isEnabled()) return;
|
||||
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "ws-staleness-monitor");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
running = true;
|
||||
scheduler.scheduleAtFixedRate(this::check, 15, 15, TimeUnit.SECONDS);
|
||||
log.info("[WebSocketStalenessMonitor] 시작 (threshold={}s)", props.getStaleTickSeconds());
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void stop() {
|
||||
running = false;
|
||||
if (scheduler != null) scheduler.shutdownNow();
|
||||
}
|
||||
|
||||
public Map<String, Object> monitorSnapshot() {
|
||||
return Map.of(
|
||||
"running", running,
|
||||
"lastCheckAtMs", lastCheckAtMs,
|
||||
"lastStaleCount", lastStaleCount,
|
||||
"staleThresholdSec", props.getStaleTickSeconds()
|
||||
);
|
||||
}
|
||||
|
||||
private void check() {
|
||||
lastCheckAtMs = System.currentTimeMillis();
|
||||
long now = System.currentTimeMillis();
|
||||
long thresholdMs = props.getStaleTickSeconds() * 1000L;
|
||||
var subscribed = upbitWebSocketClient.getSubscribedMarkets();
|
||||
if (subscribed.isEmpty()) return;
|
||||
|
||||
List<String> stale = new ArrayList<>();
|
||||
for (String market : subscribed) {
|
||||
long last = activityTracker.lastTickMs(market);
|
||||
if (last == 0) {
|
||||
// 아직 한 번도 틱 없음 — 구독 직후 30초 유예는 threshold 로 커버
|
||||
continue;
|
||||
}
|
||||
if (now - last > thresholdMs) {
|
||||
stale.add(market);
|
||||
}
|
||||
}
|
||||
lastStaleCount = stale.size();
|
||||
if (!stale.isEmpty()) {
|
||||
log.warn("[WebSocketStalenessMonitor] 틱 정체 {}종목 → WS 재연결 요청: {}",
|
||||
stale.size(), stale);
|
||||
upbitWebSocketClient.requestReconnect("stale_ticks:" + stale);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user