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,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;
}
@@ -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);
}
}
}