매매시 현재가 적용

This commit is contained in:
Macbook
2026-06-23 00:12:23 +09:00
parent 351c5600b8
commit 6e86ec7ecb
16 changed files with 221 additions and 79 deletions
@@ -32,13 +32,13 @@ public class BacktestSettingsDto {
private BigDecimal slippageRate = new BigDecimal("0.00050");
// ── 진입/청산 가격 ─────────────────────────────────────────────────────────
/** CLOSE | NEXT_OPEN */
/** CURRENT | CLOSE(레거시) | NEXT_OPEN */
@Builder.Default
private String entryPriceType = "CLOSE";
private String entryPriceType = "CURRENT";
/** CLOSE | NEXT_OPEN */
/** CURRENT | CLOSE(레거시) | NEXT_OPEN */
@Builder.Default
private String exitPriceType = "CLOSE";
private String exitPriceType = "CURRENT";
// ── 포지션 방향 ────────────────────────────────────────────────────────────
/** LONG | SHORT | BOTH */
@@ -109,7 +109,7 @@ public class BacktestSettingsDto {
private String analysisMethod = "MARK_TO_MARKET";
/**
* SCAN_SIGNALS — 조건 스캔(봉 종가·DSL 충족, 차트 마커와 동일)
* SCAN_SIGNALS — 조건 스캔(현재가·DSL 충족, 차트 마커와 동일)
* BACKTEST_ENGINE — 슬리피지·손절/익절·진입/청산가 등 백테스트 설정 반영 (기본)
*/
@Builder.Default
@@ -42,15 +42,15 @@ public class GcBacktestSettings {
private BigDecimal slippageRate = new BigDecimal("0.00050");
// ── 진입/청산 가격 ─────────────────────────────────────────────────────────
/** CLOSE | NEXT_OPEN */
/** CURRENT | CLOSE(레거시) | NEXT_OPEN */
@Column(name = "entry_price_type", nullable = false, length = 20)
@Builder.Default
private String entryPriceType = "CLOSE";
private String entryPriceType = "CURRENT";
/** CLOSE | NEXT_OPEN */
/** CURRENT | CLOSE(레거시) | NEXT_OPEN */
@Column(name = "exit_price_type", nullable = false, length = 20)
@Builder.Default
private String exitPriceType = "CLOSE";
private String exitPriceType = "CURRENT";
// ── 포지션 방향 ────────────────────────────────────────────────────────────
/** LONG | SHORT | BOTH */
@@ -33,8 +33,8 @@ public class BacktestSettingsService {
entity.setCommissionType(dto.getCommissionType());
entity.setCommissionRate(dto.getCommissionRate());
entity.setSlippageRate(dto.getSlippageRate());
entity.setEntryPriceType(dto.getEntryPriceType());
entity.setExitPriceType(dto.getExitPriceType());
entity.setEntryPriceType(TradeExecutionPriceSupport.normalizePriceType(dto.getEntryPriceType()));
entity.setExitPriceType(TradeExecutionPriceSupport.normalizePriceType(dto.getExitPriceType()));
entity.setPositionDirection(dto.getPositionDirection());
entity.setTradeSizeType(dto.getTradeSizeType());
entity.setTradeSizeValue(dto.getTradeSizeValue());
@@ -71,8 +71,8 @@ public class BacktestSettingsService {
.commissionType(e.getCommissionType())
.commissionRate(e.getCommissionRate())
.slippageRate(e.getSlippageRate())
.entryPriceType(e.getEntryPriceType())
.exitPriceType(e.getExitPriceType())
.entryPriceType(TradeExecutionPriceSupport.normalizePriceType(e.getEntryPriceType()))
.exitPriceType(TradeExecutionPriceSupport.normalizePriceType(e.getExitPriceType()))
.positionDirection(e.getPositionDirection())
.tradeSizeType(e.getTradeSizeType())
.tradeSizeValue(e.getTradeSizeValue())
@@ -50,7 +50,7 @@ public class BacktestingService {
private static final BacktestSettingsDto DEFAULT_SETTINGS = new BacktestSettingsDto();
/** 조건 스캔 — 가·DSL 충족 (슬리피지·손절/익절 Rule 미적용) */
/** 조건 스캔 — 현재가·DSL 충족 (슬리피지·손절/익절 Rule 미적용) */
public static final String TRADE_EXEC_SCAN_SIGNALS = "SCAN_SIGNALS";
/** 백테스트 엔진 — 슬리피지·리스크 Rule·진입/청산가 반영 */
public static final String TRADE_EXEC_BACKTEST_ENGINE = "BACKTEST_ENGINE";
@@ -221,8 +221,12 @@ public class BacktestingService {
int barCount = series.getBarCount();
int loopStart = Math.max(0, Math.min(evalStartIndex, barCount));
final String entryPriceType = scanExec ? "CLOSE" : cfg.getEntryPriceType();
final String exitPriceType = scanExec ? "CLOSE" : cfg.getExitPriceType();
final String entryPriceType = scanExec
? TradeExecutionPriceSupport.PRICE_CURRENT
: cfg.getEntryPriceType();
final String exitPriceType = scanExec
? TradeExecutionPriceSupport.PRICE_CURRENT
: cfg.getExitPriceType();
for (int i = loopStart; i < barCount; i++) {
double closePrice = getPrice(series, req.getBars(), i, entryPriceType);
@@ -730,15 +734,7 @@ public class BacktestingService {
// ── 가격 결정 ─────────────────────────────────────────────────────────────
private double getPrice(BarSeries series, List<OhlcvBar> bars, int i, String priceType) {
if ("NEXT_OPEN".equals(priceType) && i + 1 < bars.size())
return bars.get(i + 1).getOpen();
if ("OPEN".equals(priceType))
return series.getBar(i).getOpenPrice().doubleValue();
if ("HIGH".equals(priceType))
return series.getBar(i).getHighPrice().doubleValue();
if ("LOW".equals(priceType))
return series.getBar(i).getLowPrice().doubleValue();
return series.getBar(i).getClosePrice().doubleValue();
return TradeExecutionPriceSupport.backtestExecutionPrice(series, bars, i, priceType);
}
private double applySlippage(double price, BacktestSettingsDto cfg, boolean isBuy) {
@@ -79,12 +79,14 @@ public class BarCloseStrategyEvaluationService {
if (!"BUY".equals(closeSignal) && !"SELL".equals(closeSignal)) continue;
publishStrategySignal(market, ct, signalBar, closeSignal, s, signalExecType);
double execPrice = TradeExecutionPriceSupport.resolveLiveExecutionPrice(
ta4jStorage, market, ct, signalBar);
publishStrategySignal(market, ct, signalBar, closeSignal, s, signalExecType, execPrice);
try {
tradeSignalService.save(
s.getDeviceId(), s.getUserId(),
market, s.getStrategyId(), null,
closeSignal, signalBar.getClosePrice().doubleValue(),
closeSignal, execPrice,
candleTimeEpoch, ct, signalExecType);
if (s.getUserId() != null) {
String strategyName = tradeSignalService.resolveStrategyName(
@@ -92,7 +94,7 @@ public class BarCloseStrategyEvaluationService {
orderExecutionQueue.submitSignal(
s.getUserId(), market,
s.getStrategyId(), closeSignal,
signalBar.getClosePrice().doubleValue(),
execPrice,
ct, strategyName, signalExecType);
}
} catch (Exception e) {
@@ -138,8 +140,12 @@ public class BarCloseStrategyEvaluationService {
}
private void publishStrategySignal(String market, String candleType, Bar bar, String signal,
GcLiveStrategySettings setting, String executionType) {
GcLiveStrategySettings setting, String executionType,
double executionPrice) {
long barStartEpoch = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
double signalPrice = executionPrice > 0
? executionPrice
: TradeExecutionPriceSupport.signalBarPrice(bar);
// [기존] 차트 캔들 스트림에 signal 필드 포함하여 발행 (차트 마커 렌더링용, 하위 호환)
CandleBarDto dto = CandleBarDto.builder()
@@ -164,15 +170,15 @@ public class BarCloseStrategyEvaluationService {
.candleType(candleType)
.strategyId(setting.getStrategyId())
.signalType(signal)
.price(bar.getClosePrice().doubleValue())
.price(signalPrice)
.candleTime(barStartEpoch)
.executionType(executionType)
.timestamp(System.currentTimeMillis())
.build();
signalEventBroker.publishToUser(setting.getUserId(), setting.getDeviceId(), signalEvent);
log.info("[BarCloseEval] bar-close signal={} market={} candleType={} strategyId={} userId={}",
signal, market, candleType, setting.getStrategyId(), setting.getUserId());
log.info("[BarCloseEval] bar-close signal={} market={} candleType={} strategyId={} userId={} price={}",
signal, market, candleType, setting.getStrategyId(), setting.getUserId(), signalPrice);
}
private void trimEvaluatedKeys() {
@@ -816,7 +816,7 @@ public class LiveConditionStatusService {
boolean inPosition = false;
for (int i = startIdx; i <= primarySeries.getEndIndex(); i++) {
long barTimeSec = barOpenEpochSec(primarySeries, i);
double close = primarySeries.getBar(i).getClosePrice().doubleValue();
double execPrice = TradeExecutionPriceSupport.signalBarPrice(primarySeries, i);
if (signalOnly) {
if (entryRule.isSatisfied(i, null)) {
@@ -824,7 +824,7 @@ public class LiveConditionStatusService {
signals.add(BacktestResponse.Signal.builder()
.time(barTimeSec)
.type("BUY")
.price(close)
.price(execPrice)
.barIndex(i)
.quantity(0)
.build());
@@ -834,7 +834,7 @@ public class LiveConditionStatusService {
signals.add(BacktestResponse.Signal.builder()
.time(barTimeSec)
.type("SELL")
.price(close)
.price(execPrice)
.barIndex(i)
.quantity(0)
.build());
@@ -848,11 +848,11 @@ public class LiveConditionStatusService {
signals.add(BacktestResponse.Signal.builder()
.time(barTimeSec)
.type("BUY")
.price(close)
.price(execPrice)
.barIndex(i)
.quantity(0)
.build());
record.enter(i, primarySeries.numFactory().numOf(close),
record.enter(i, primarySeries.numFactory().numOf(execPrice),
primarySeries.numFactory().numOf(1));
inPosition = true;
}
@@ -861,11 +861,11 @@ public class LiveConditionStatusService {
signals.add(BacktestResponse.Signal.builder()
.time(barTimeSec)
.type("SELL")
.price(close)
.price(execPrice)
.barIndex(i)
.quantity(0)
.build());
record.exit(i, primarySeries.numFactory().numOf(close),
record.exit(i, primarySeries.numFactory().numOf(execPrice),
primarySeries.numFactory().numOf(1));
inPosition = false;
}
@@ -492,16 +492,7 @@ public class PaperTradingService {
}
public double resolveMarkPrice(String symbol) {
try {
if (!ta4jStorage.exists(symbol, "1m")) return 0;
BarSeries series = ta4jStorage.getOrCreate(symbol, "1m");
if (!series.isEmpty()) {
Bar last = series.getLastBar();
return last.getClosePrice().doubleValue();
}
} catch (Exception ignored) {
}
return 0;
return TradeExecutionPriceSupport.resolveLiveExecutionPrice(ta4jStorage, symbol, "1m", null);
}
public PaperSummaryDto resetAccount(Long userId) {
@@ -0,0 +1,96 @@
package com.goldenchart.service;
import com.goldenchart.dto.OhlcvBar;
import com.goldenchart.storage.Ta4jStorage;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import java.util.List;
/**
* 매매 시그널 체결가 — 종가·시장가 대신 현재가(해당 시점 최신 체결가) 기준.
*
* <p>히스토리 백테스트: 시그널 봉의 종가 = 그 시점의 현재가.
* <p>실시간: ta4j 시리즈 최신 봉 종가(틱 갱신) 우선.
*/
public final class TradeExecutionPriceSupport {
public static final String PRICE_CURRENT = "CURRENT";
/** 레거시 — CURRENT 와 동일 취급 */
public static final String PRICE_CLOSE = "CLOSE";
public static final String PRICE_NEXT_OPEN = "NEXT_OPEN";
private TradeExecutionPriceSupport() {
}
/** CLOSE·MARKET·blank → CURRENT */
public static String normalizePriceType(String priceType) {
if (priceType == null || priceType.isBlank()) return PRICE_CURRENT;
return switch (priceType.toUpperCase()) {
case PRICE_CLOSE, "MARKET" -> PRICE_CURRENT;
default -> priceType.toUpperCase();
};
}
/**
* 백테스트·조건 스캔 — 시그널 발생 봉의 체결가.
*/
public static double signalBarPrice(BarSeries series, int barIndex) {
if (series == null || barIndex < series.getBeginIndex() || barIndex > series.getEndIndex()) {
return 0.0;
}
return series.getBar(barIndex).getClosePrice().doubleValue();
}
public static double signalBarPrice(Bar bar) {
if (bar == null) return 0.0;
return bar.getClosePrice().doubleValue();
}
/**
* 실시간 체결가 — 차트 분봉·1m 시리즈 최신 틱가, 없으면 fallback 봉 종가.
*/
public static double resolveLiveExecutionPrice(Ta4jStorage storage, String market,
String candleType, Bar fallbackBar) {
double fromSeries = lastSeriesClosePrice(storage, market, candleType);
if (fromSeries > 0) return fromSeries;
fromSeries = lastSeriesClosePrice(storage, market, "1m");
if (fromSeries > 0) return fromSeries;
return signalBarPrice(fallbackBar);
}
/**
* 백테스트 진입/청산가 — entryPriceType / exitPriceType.
*/
public static double backtestExecutionPrice(BarSeries series, List<OhlcvBar> bars,
int barIndex, String priceType) {
String normalized = normalizePriceType(priceType);
if (PRICE_NEXT_OPEN.equals(normalized) && bars != null && barIndex + 1 < bars.size()) {
return bars.get(barIndex + 1).getOpen();
}
if ("OPEN".equals(normalized)) {
return series.getBar(barIndex).getOpenPrice().doubleValue();
}
if ("HIGH".equals(normalized)) {
return series.getBar(barIndex).getHighPrice().doubleValue();
}
if ("LOW".equals(normalized)) {
return series.getBar(barIndex).getLowPrice().doubleValue();
}
// CURRENT (기본) — 시그널 봉 현재가 = 종가
return signalBarPrice(series, barIndex);
}
private static double lastSeriesClosePrice(Ta4jStorage storage, String market, String candleType) {
if (storage == null || market == null || candleType == null) return 0.0;
try {
if (!storage.exists(market, candleType)) return 0.0;
BarSeries series = storage.getOrCreate(market, candleType);
if (series.isEmpty()) return 0.0;
double p = series.getLastBar().getClosePrice().doubleValue();
return p > 0 ? p : 0.0;
} catch (Exception ignored) {
return 0.0;
}
}
}
@@ -6,6 +6,7 @@ import com.goldenchart.entity.GcLiveStrategySettings;
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
import com.goldenchart.service.LiveStrategyEvaluator;
import com.goldenchart.service.StrategyConditionTimeframeService;
import com.goldenchart.service.TradeExecutionPriceSupport;
import com.goldenchart.service.TradeSignalService;
import com.goldenchart.trading.pipeline.OrderExecutionQueue;
import com.goldenchart.storage.Ta4jStorage;
@@ -74,9 +75,9 @@ public class LiveStrategyScheduler {
String signal = evaluator.evaluateSettingRealtimeTick(s, market, candleType);
if ("BUY".equals(signal) || "SELL".equals(signal)) {
// 현재 진행 중인 캔들 정보 조회
org.ta4j.core.Bar lastBar = series.getLastBar();
// Ta4j 0.22: getEndTime() → Instant; 시작 시간 = endTime - duration
double execPrice = TradeExecutionPriceSupport.resolveLiveExecutionPrice(
ta4jStorage, market, candleType, lastBar);
long barStartEpoch = lastBar.getEndTime().getEpochSecond()
- lastBar.getTimePeriod().getSeconds();
@@ -103,7 +104,7 @@ public class LiveStrategyScheduler {
.candleType(candleType)
.strategyId(s.getStrategyId())
.signalType(signal)
.price(lastBar.getClosePrice().doubleValue())
.price(execPrice)
.candleTime(barStartEpoch)
.executionType("REALTIME_TICK")
.timestamp(System.currentTimeMillis())
@@ -118,7 +119,7 @@ public class LiveStrategyScheduler {
s.getDeviceId(), s.getUserId(),
market, s.getStrategyId(), null,
signal,
lastBar.getClosePrice().doubleValue(),
execPrice,
barStartEpoch, candleType, "REALTIME_TICK"
);
if (s.getUserId() != null) {
@@ -127,7 +128,7 @@ public class LiveStrategyScheduler {
orderExecutionQueue.submitSignal(
s.getUserId(), market,
s.getStrategyId(), signal,
lastBar.getClosePrice().doubleValue(),
execPrice,
candleType, strategyName, "REALTIME_TICK");
}
} catch (Exception ex) {