매매시 현재가 적용
This commit is contained in:
@@ -32,13 +32,13 @@ public class BacktestSettingsDto {
|
|||||||
private BigDecimal slippageRate = new BigDecimal("0.00050");
|
private BigDecimal slippageRate = new BigDecimal("0.00050");
|
||||||
|
|
||||||
// ── 진입/청산 가격 ─────────────────────────────────────────────────────────
|
// ── 진입/청산 가격 ─────────────────────────────────────────────────────────
|
||||||
/** CLOSE | NEXT_OPEN */
|
/** CURRENT | CLOSE(레거시) | NEXT_OPEN */
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private String entryPriceType = "CLOSE";
|
private String entryPriceType = "CURRENT";
|
||||||
|
|
||||||
/** CLOSE | NEXT_OPEN */
|
/** CURRENT | CLOSE(레거시) | NEXT_OPEN */
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private String exitPriceType = "CLOSE";
|
private String exitPriceType = "CURRENT";
|
||||||
|
|
||||||
// ── 포지션 방향 ────────────────────────────────────────────────────────────
|
// ── 포지션 방향 ────────────────────────────────────────────────────────────
|
||||||
/** LONG | SHORT | BOTH */
|
/** LONG | SHORT | BOTH */
|
||||||
@@ -109,7 +109,7 @@ public class BacktestSettingsDto {
|
|||||||
private String analysisMethod = "MARK_TO_MARKET";
|
private String analysisMethod = "MARK_TO_MARKET";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SCAN_SIGNALS — 조건 스캔(봉 종가·DSL 충족, 차트 마커와 동일)
|
* SCAN_SIGNALS — 조건 스캔(현재가·DSL 충족, 차트 마커와 동일)
|
||||||
* BACKTEST_ENGINE — 슬리피지·손절/익절·진입/청산가 등 백테스트 설정 반영 (기본)
|
* BACKTEST_ENGINE — 슬리피지·손절/익절·진입/청산가 등 백테스트 설정 반영 (기본)
|
||||||
*/
|
*/
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
|
|||||||
@@ -42,15 +42,15 @@ public class GcBacktestSettings {
|
|||||||
private BigDecimal slippageRate = new BigDecimal("0.00050");
|
private BigDecimal slippageRate = new BigDecimal("0.00050");
|
||||||
|
|
||||||
// ── 진입/청산 가격 ─────────────────────────────────────────────────────────
|
// ── 진입/청산 가격 ─────────────────────────────────────────────────────────
|
||||||
/** CLOSE | NEXT_OPEN */
|
/** CURRENT | CLOSE(레거시) | NEXT_OPEN */
|
||||||
@Column(name = "entry_price_type", nullable = false, length = 20)
|
@Column(name = "entry_price_type", nullable = false, length = 20)
|
||||||
@Builder.Default
|
@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)
|
@Column(name = "exit_price_type", nullable = false, length = 20)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private String exitPriceType = "CLOSE";
|
private String exitPriceType = "CURRENT";
|
||||||
|
|
||||||
// ── 포지션 방향 ────────────────────────────────────────────────────────────
|
// ── 포지션 방향 ────────────────────────────────────────────────────────────
|
||||||
/** LONG | SHORT | BOTH */
|
/** LONG | SHORT | BOTH */
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ public class BacktestSettingsService {
|
|||||||
entity.setCommissionType(dto.getCommissionType());
|
entity.setCommissionType(dto.getCommissionType());
|
||||||
entity.setCommissionRate(dto.getCommissionRate());
|
entity.setCommissionRate(dto.getCommissionRate());
|
||||||
entity.setSlippageRate(dto.getSlippageRate());
|
entity.setSlippageRate(dto.getSlippageRate());
|
||||||
entity.setEntryPriceType(dto.getEntryPriceType());
|
entity.setEntryPriceType(TradeExecutionPriceSupport.normalizePriceType(dto.getEntryPriceType()));
|
||||||
entity.setExitPriceType(dto.getExitPriceType());
|
entity.setExitPriceType(TradeExecutionPriceSupport.normalizePriceType(dto.getExitPriceType()));
|
||||||
entity.setPositionDirection(dto.getPositionDirection());
|
entity.setPositionDirection(dto.getPositionDirection());
|
||||||
entity.setTradeSizeType(dto.getTradeSizeType());
|
entity.setTradeSizeType(dto.getTradeSizeType());
|
||||||
entity.setTradeSizeValue(dto.getTradeSizeValue());
|
entity.setTradeSizeValue(dto.getTradeSizeValue());
|
||||||
@@ -71,8 +71,8 @@ public class BacktestSettingsService {
|
|||||||
.commissionType(e.getCommissionType())
|
.commissionType(e.getCommissionType())
|
||||||
.commissionRate(e.getCommissionRate())
|
.commissionRate(e.getCommissionRate())
|
||||||
.slippageRate(e.getSlippageRate())
|
.slippageRate(e.getSlippageRate())
|
||||||
.entryPriceType(e.getEntryPriceType())
|
.entryPriceType(TradeExecutionPriceSupport.normalizePriceType(e.getEntryPriceType()))
|
||||||
.exitPriceType(e.getExitPriceType())
|
.exitPriceType(TradeExecutionPriceSupport.normalizePriceType(e.getExitPriceType()))
|
||||||
.positionDirection(e.getPositionDirection())
|
.positionDirection(e.getPositionDirection())
|
||||||
.tradeSizeType(e.getTradeSizeType())
|
.tradeSizeType(e.getTradeSizeType())
|
||||||
.tradeSizeValue(e.getTradeSizeValue())
|
.tradeSizeValue(e.getTradeSizeValue())
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ public class BacktestingService {
|
|||||||
|
|
||||||
private static final BacktestSettingsDto DEFAULT_SETTINGS = new BacktestSettingsDto();
|
private static final BacktestSettingsDto DEFAULT_SETTINGS = new BacktestSettingsDto();
|
||||||
|
|
||||||
/** 조건 스캔 — 종가·DSL 충족 (슬리피지·손절/익절 Rule 미적용) */
|
/** 조건 스캔 — 현재가·DSL 충족 (슬리피지·손절/익절 Rule 미적용) */
|
||||||
public static final String TRADE_EXEC_SCAN_SIGNALS = "SCAN_SIGNALS";
|
public static final String TRADE_EXEC_SCAN_SIGNALS = "SCAN_SIGNALS";
|
||||||
/** 백테스트 엔진 — 슬리피지·리스크 Rule·진입/청산가 반영 */
|
/** 백테스트 엔진 — 슬리피지·리스크 Rule·진입/청산가 반영 */
|
||||||
public static final String TRADE_EXEC_BACKTEST_ENGINE = "BACKTEST_ENGINE";
|
public static final String TRADE_EXEC_BACKTEST_ENGINE = "BACKTEST_ENGINE";
|
||||||
@@ -221,8 +221,12 @@ public class BacktestingService {
|
|||||||
|
|
||||||
int barCount = series.getBarCount();
|
int barCount = series.getBarCount();
|
||||||
int loopStart = Math.max(0, Math.min(evalStartIndex, barCount));
|
int loopStart = Math.max(0, Math.min(evalStartIndex, barCount));
|
||||||
final String entryPriceType = scanExec ? "CLOSE" : cfg.getEntryPriceType();
|
final String entryPriceType = scanExec
|
||||||
final String exitPriceType = scanExec ? "CLOSE" : cfg.getExitPriceType();
|
? TradeExecutionPriceSupport.PRICE_CURRENT
|
||||||
|
: cfg.getEntryPriceType();
|
||||||
|
final String exitPriceType = scanExec
|
||||||
|
? TradeExecutionPriceSupport.PRICE_CURRENT
|
||||||
|
: cfg.getExitPriceType();
|
||||||
|
|
||||||
for (int i = loopStart; i < barCount; i++) {
|
for (int i = loopStart; i < barCount; i++) {
|
||||||
double closePrice = getPrice(series, req.getBars(), i, entryPriceType);
|
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) {
|
private double getPrice(BarSeries series, List<OhlcvBar> bars, int i, String priceType) {
|
||||||
if ("NEXT_OPEN".equals(priceType) && i + 1 < bars.size())
|
return TradeExecutionPriceSupport.backtestExecutionPrice(series, bars, i, priceType);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private double applySlippage(double price, BacktestSettingsDto cfg, boolean isBuy) {
|
private double applySlippage(double price, BacktestSettingsDto cfg, boolean isBuy) {
|
||||||
|
|||||||
+13
-7
@@ -79,12 +79,14 @@ public class BarCloseStrategyEvaluationService {
|
|||||||
|
|
||||||
if (!"BUY".equals(closeSignal) && !"SELL".equals(closeSignal)) continue;
|
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 {
|
try {
|
||||||
tradeSignalService.save(
|
tradeSignalService.save(
|
||||||
s.getDeviceId(), s.getUserId(),
|
s.getDeviceId(), s.getUserId(),
|
||||||
market, s.getStrategyId(), null,
|
market, s.getStrategyId(), null,
|
||||||
closeSignal, signalBar.getClosePrice().doubleValue(),
|
closeSignal, execPrice,
|
||||||
candleTimeEpoch, ct, signalExecType);
|
candleTimeEpoch, ct, signalExecType);
|
||||||
if (s.getUserId() != null) {
|
if (s.getUserId() != null) {
|
||||||
String strategyName = tradeSignalService.resolveStrategyName(
|
String strategyName = tradeSignalService.resolveStrategyName(
|
||||||
@@ -92,7 +94,7 @@ public class BarCloseStrategyEvaluationService {
|
|||||||
orderExecutionQueue.submitSignal(
|
orderExecutionQueue.submitSignal(
|
||||||
s.getUserId(), market,
|
s.getUserId(), market,
|
||||||
s.getStrategyId(), closeSignal,
|
s.getStrategyId(), closeSignal,
|
||||||
signalBar.getClosePrice().doubleValue(),
|
execPrice,
|
||||||
ct, strategyName, signalExecType);
|
ct, strategyName, signalExecType);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -138,8 +140,12 @@ public class BarCloseStrategyEvaluationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void publishStrategySignal(String market, String candleType, Bar bar, String signal,
|
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();
|
long barStartEpoch = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
|
||||||
|
double signalPrice = executionPrice > 0
|
||||||
|
? executionPrice
|
||||||
|
: TradeExecutionPriceSupport.signalBarPrice(bar);
|
||||||
|
|
||||||
// [기존] 차트 캔들 스트림에 signal 필드 포함하여 발행 (차트 마커 렌더링용, 하위 호환)
|
// [기존] 차트 캔들 스트림에 signal 필드 포함하여 발행 (차트 마커 렌더링용, 하위 호환)
|
||||||
CandleBarDto dto = CandleBarDto.builder()
|
CandleBarDto dto = CandleBarDto.builder()
|
||||||
@@ -164,15 +170,15 @@ public class BarCloseStrategyEvaluationService {
|
|||||||
.candleType(candleType)
|
.candleType(candleType)
|
||||||
.strategyId(setting.getStrategyId())
|
.strategyId(setting.getStrategyId())
|
||||||
.signalType(signal)
|
.signalType(signal)
|
||||||
.price(bar.getClosePrice().doubleValue())
|
.price(signalPrice)
|
||||||
.candleTime(barStartEpoch)
|
.candleTime(barStartEpoch)
|
||||||
.executionType(executionType)
|
.executionType(executionType)
|
||||||
.timestamp(System.currentTimeMillis())
|
.timestamp(System.currentTimeMillis())
|
||||||
.build();
|
.build();
|
||||||
signalEventBroker.publishToUser(setting.getUserId(), setting.getDeviceId(), signalEvent);
|
signalEventBroker.publishToUser(setting.getUserId(), setting.getDeviceId(), signalEvent);
|
||||||
|
|
||||||
log.info("[BarCloseEval] bar-close signal={} market={} candleType={} strategyId={} userId={}",
|
log.info("[BarCloseEval] bar-close signal={} market={} candleType={} strategyId={} userId={} price={}",
|
||||||
signal, market, candleType, setting.getStrategyId(), setting.getUserId());
|
signal, market, candleType, setting.getStrategyId(), setting.getUserId(), signalPrice);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void trimEvaluatedKeys() {
|
private void trimEvaluatedKeys() {
|
||||||
|
|||||||
@@ -816,7 +816,7 @@ public class LiveConditionStatusService {
|
|||||||
boolean inPosition = false;
|
boolean inPosition = false;
|
||||||
for (int i = startIdx; i <= primarySeries.getEndIndex(); i++) {
|
for (int i = startIdx; i <= primarySeries.getEndIndex(); i++) {
|
||||||
long barTimeSec = barOpenEpochSec(primarySeries, i);
|
long barTimeSec = barOpenEpochSec(primarySeries, i);
|
||||||
double close = primarySeries.getBar(i).getClosePrice().doubleValue();
|
double execPrice = TradeExecutionPriceSupport.signalBarPrice(primarySeries, i);
|
||||||
|
|
||||||
if (signalOnly) {
|
if (signalOnly) {
|
||||||
if (entryRule.isSatisfied(i, null)) {
|
if (entryRule.isSatisfied(i, null)) {
|
||||||
@@ -824,7 +824,7 @@ public class LiveConditionStatusService {
|
|||||||
signals.add(BacktestResponse.Signal.builder()
|
signals.add(BacktestResponse.Signal.builder()
|
||||||
.time(barTimeSec)
|
.time(barTimeSec)
|
||||||
.type("BUY")
|
.type("BUY")
|
||||||
.price(close)
|
.price(execPrice)
|
||||||
.barIndex(i)
|
.barIndex(i)
|
||||||
.quantity(0)
|
.quantity(0)
|
||||||
.build());
|
.build());
|
||||||
@@ -834,7 +834,7 @@ public class LiveConditionStatusService {
|
|||||||
signals.add(BacktestResponse.Signal.builder()
|
signals.add(BacktestResponse.Signal.builder()
|
||||||
.time(barTimeSec)
|
.time(barTimeSec)
|
||||||
.type("SELL")
|
.type("SELL")
|
||||||
.price(close)
|
.price(execPrice)
|
||||||
.barIndex(i)
|
.barIndex(i)
|
||||||
.quantity(0)
|
.quantity(0)
|
||||||
.build());
|
.build());
|
||||||
@@ -848,11 +848,11 @@ public class LiveConditionStatusService {
|
|||||||
signals.add(BacktestResponse.Signal.builder()
|
signals.add(BacktestResponse.Signal.builder()
|
||||||
.time(barTimeSec)
|
.time(barTimeSec)
|
||||||
.type("BUY")
|
.type("BUY")
|
||||||
.price(close)
|
.price(execPrice)
|
||||||
.barIndex(i)
|
.barIndex(i)
|
||||||
.quantity(0)
|
.quantity(0)
|
||||||
.build());
|
.build());
|
||||||
record.enter(i, primarySeries.numFactory().numOf(close),
|
record.enter(i, primarySeries.numFactory().numOf(execPrice),
|
||||||
primarySeries.numFactory().numOf(1));
|
primarySeries.numFactory().numOf(1));
|
||||||
inPosition = true;
|
inPosition = true;
|
||||||
}
|
}
|
||||||
@@ -861,11 +861,11 @@ public class LiveConditionStatusService {
|
|||||||
signals.add(BacktestResponse.Signal.builder()
|
signals.add(BacktestResponse.Signal.builder()
|
||||||
.time(barTimeSec)
|
.time(barTimeSec)
|
||||||
.type("SELL")
|
.type("SELL")
|
||||||
.price(close)
|
.price(execPrice)
|
||||||
.barIndex(i)
|
.barIndex(i)
|
||||||
.quantity(0)
|
.quantity(0)
|
||||||
.build());
|
.build());
|
||||||
record.exit(i, primarySeries.numFactory().numOf(close),
|
record.exit(i, primarySeries.numFactory().numOf(execPrice),
|
||||||
primarySeries.numFactory().numOf(1));
|
primarySeries.numFactory().numOf(1));
|
||||||
inPosition = false;
|
inPosition = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -492,16 +492,7 @@ public class PaperTradingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public double resolveMarkPrice(String symbol) {
|
public double resolveMarkPrice(String symbol) {
|
||||||
try {
|
return TradeExecutionPriceSupport.resolveLiveExecutionPrice(ta4jStorage, symbol, "1m", null);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public PaperSummaryDto resetAccount(Long userId) {
|
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.repository.GcLiveStrategySettingsRepository;
|
||||||
import com.goldenchart.service.LiveStrategyEvaluator;
|
import com.goldenchart.service.LiveStrategyEvaluator;
|
||||||
import com.goldenchart.service.StrategyConditionTimeframeService;
|
import com.goldenchart.service.StrategyConditionTimeframeService;
|
||||||
|
import com.goldenchart.service.TradeExecutionPriceSupport;
|
||||||
import com.goldenchart.service.TradeSignalService;
|
import com.goldenchart.service.TradeSignalService;
|
||||||
import com.goldenchart.trading.pipeline.OrderExecutionQueue;
|
import com.goldenchart.trading.pipeline.OrderExecutionQueue;
|
||||||
import com.goldenchart.storage.Ta4jStorage;
|
import com.goldenchart.storage.Ta4jStorage;
|
||||||
@@ -74,9 +75,9 @@ public class LiveStrategyScheduler {
|
|||||||
|
|
||||||
String signal = evaluator.evaluateSettingRealtimeTick(s, market, candleType);
|
String signal = evaluator.evaluateSettingRealtimeTick(s, market, candleType);
|
||||||
if ("BUY".equals(signal) || "SELL".equals(signal)) {
|
if ("BUY".equals(signal) || "SELL".equals(signal)) {
|
||||||
// 현재 진행 중인 캔들 정보 조회
|
|
||||||
org.ta4j.core.Bar lastBar = series.getLastBar();
|
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()
|
long barStartEpoch = lastBar.getEndTime().getEpochSecond()
|
||||||
- lastBar.getTimePeriod().getSeconds();
|
- lastBar.getTimePeriod().getSeconds();
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ public class LiveStrategyScheduler {
|
|||||||
.candleType(candleType)
|
.candleType(candleType)
|
||||||
.strategyId(s.getStrategyId())
|
.strategyId(s.getStrategyId())
|
||||||
.signalType(signal)
|
.signalType(signal)
|
||||||
.price(lastBar.getClosePrice().doubleValue())
|
.price(execPrice)
|
||||||
.candleTime(barStartEpoch)
|
.candleTime(barStartEpoch)
|
||||||
.executionType("REALTIME_TICK")
|
.executionType("REALTIME_TICK")
|
||||||
.timestamp(System.currentTimeMillis())
|
.timestamp(System.currentTimeMillis())
|
||||||
@@ -118,7 +119,7 @@ public class LiveStrategyScheduler {
|
|||||||
s.getDeviceId(), s.getUserId(),
|
s.getDeviceId(), s.getUserId(),
|
||||||
market, s.getStrategyId(), null,
|
market, s.getStrategyId(), null,
|
||||||
signal,
|
signal,
|
||||||
lastBar.getClosePrice().doubleValue(),
|
execPrice,
|
||||||
barStartEpoch, candleType, "REALTIME_TICK"
|
barStartEpoch, candleType, "REALTIME_TICK"
|
||||||
);
|
);
|
||||||
if (s.getUserId() != null) {
|
if (s.getUserId() != null) {
|
||||||
@@ -127,7 +128,7 @@ public class LiveStrategyScheduler {
|
|||||||
orderExecutionQueue.submitSignal(
|
orderExecutionQueue.submitSignal(
|
||||||
s.getUserId(), market,
|
s.getUserId(), market,
|
||||||
s.getStrategyId(), signal,
|
s.getStrategyId(), signal,
|
||||||
lastBar.getClosePrice().doubleValue(),
|
execPrice,
|
||||||
candleType, strategyName, "REALTIME_TICK");
|
candleType, strategyName, "REALTIME_TICK");
|
||||||
}
|
}
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.goldenchart.service;
|
||||||
|
|
||||||
|
import com.goldenchart.dto.OhlcvBar;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.ta4j.core.BarSeries;
|
||||||
|
import org.ta4j.core.BaseBarSeriesBuilder;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
class TradeExecutionPriceSupportTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void normalizePriceType_mapsCloseAndMarketToCurrent() {
|
||||||
|
assertEquals("CURRENT", TradeExecutionPriceSupport.normalizePriceType("CLOSE"));
|
||||||
|
assertEquals("CURRENT", TradeExecutionPriceSupport.normalizePriceType("MARKET"));
|
||||||
|
assertEquals("NEXT_OPEN", TradeExecutionPriceSupport.normalizePriceType("next_open"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backtestExecutionPrice_currentUsesSignalBarClose() {
|
||||||
|
BarSeries series = new BaseBarSeriesBuilder().build();
|
||||||
|
Instant t0 = Instant.parse("2024-01-01T00:00:00Z");
|
||||||
|
series.barBuilder()
|
||||||
|
.timePeriod(Duration.ofMinutes(1))
|
||||||
|
.endTime(t0.plus(Duration.ofMinutes(1)))
|
||||||
|
.openPrice(100).highPrice(110).lowPrice(90).closePrice(105)
|
||||||
|
.add();
|
||||||
|
series.barBuilder()
|
||||||
|
.timePeriod(Duration.ofMinutes(1))
|
||||||
|
.endTime(t0.plus(Duration.ofMinutes(2)))
|
||||||
|
.openPrice(105).highPrice(120).lowPrice(100).closePrice(115)
|
||||||
|
.add();
|
||||||
|
|
||||||
|
List<OhlcvBar> bars = List.of(
|
||||||
|
OhlcvBar.builder().time(t0.getEpochSecond()).open(100).high(110).low(90).close(105).build(),
|
||||||
|
OhlcvBar.builder().time(t0.plus(Duration.ofMinutes(1)).getEpochSecond())
|
||||||
|
.open(105).high(120).low(100).close(115).build()
|
||||||
|
);
|
||||||
|
|
||||||
|
double current = TradeExecutionPriceSupport.backtestExecutionPrice(
|
||||||
|
series, bars, 0, "CURRENT");
|
||||||
|
assertEquals(105.0, current, 1e-9);
|
||||||
|
|
||||||
|
double nextOpen = TradeExecutionPriceSupport.backtestExecutionPrice(
|
||||||
|
series, bars, 0, "NEXT_OPEN");
|
||||||
|
assertEquals(105.0, nextOpen, 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ const FIELD_META: Partial<Record<keyof BacktestSettingsDto, FieldMeta>> = {
|
|||||||
tradeExecutionMode: {
|
tradeExecutionMode: {
|
||||||
label: '매매 체결 방식',
|
label: '매매 체결 방식',
|
||||||
description:
|
description:
|
||||||
'시그널·체결가 산출 방식입니다.\n• 백테스트 엔진: 슬리피지, 손절/익절, 진입/청산가 반영. 실제 수익률 분석에 가깝습니다.\n• 조건 스캔: 봉 종가·DSL 충족 기준. 차트 마커·조건 패널과 동일합니다.',
|
'시그널·체결가 산출 방식입니다.\n• 백테스트 엔진: 슬리피지, 손절/익절, 진입/청산가 반영. 실제 수익률 분석에 가깝습니다.\n• 조건 스캔: 현재가·DSL 충족 기준. 차트 마커·조건 패널과 동일합니다.',
|
||||||
},
|
},
|
||||||
positionMode: {
|
positionMode: {
|
||||||
label: '시그널 생성 방식',
|
label: '시그널 생성 방식',
|
||||||
@@ -53,12 +53,12 @@ const FIELD_META: Partial<Record<keyof BacktestSettingsDto, FieldMeta>> = {
|
|||||||
entryPriceType: {
|
entryPriceType: {
|
||||||
label: '진입 가격 기준',
|
label: '진입 가격 기준',
|
||||||
description:
|
description:
|
||||||
'매수 신호 발생 시 어느 가격에 진입하는지 결정합니다.\n• CLOSE(종가): 신호 발생 봉의 종가로 즉시 진입. 구현이 단순하지만 후행성(look-ahead bias) 우려가 있습니다.\n• NEXT_OPEN(다음봉 시가): 신호 다음 봉 시가에 진입. 실제 트레이딩과 가장 유사한 현실적 시뮬레이션입니다.',
|
'매수 신호 발생 시 어느 가격에 진입하는지 결정합니다.\n• CURRENT(현재가): 신호 발생 시점의 현재가로 즉시 진입.\n• NEXT_OPEN(다음봉 시가): 신호 다음 봉 시가에 진입. 실제 트레이딩과 가장 유사한 현실적 시뮬레이션입니다.',
|
||||||
},
|
},
|
||||||
exitPriceType: {
|
exitPriceType: {
|
||||||
label: '청산 가격 기준',
|
label: '청산 가격 기준',
|
||||||
description:
|
description:
|
||||||
'매도 신호 발생 시 어느 가격에 청산하는지 결정합니다.\n• CLOSE(종가): 신호 발생 봉의 종가로 즉시 청산합니다.\n• NEXT_OPEN(다음봉 시가): 신호 다음 봉 시가에 청산. 야간 갭 하락 등 실제 리스크를 반영합니다.',
|
'매도 신호 발생 시 어느 가격에 청산하는지 결정합니다.\n• CURRENT(현재가): 신호 발생 시점의 현재가로 즉시 청산합니다.\n• NEXT_OPEN(다음봉 시가): 신호 다음 봉 시가에 청산. 야간 갭 하락 등 실제 리스크를 반영합니다.',
|
||||||
},
|
},
|
||||||
positionDirection: {
|
positionDirection: {
|
||||||
label: '포지션 방향',
|
label: '포지션 방향',
|
||||||
@@ -151,7 +151,7 @@ const SECTIONS: SettingSection[] = [
|
|||||||
key: 'tradeExecutionMode', type: 'select',
|
key: 'tradeExecutionMode', type: 'select',
|
||||||
opts: [
|
opts: [
|
||||||
{ value: 'BACKTEST_ENGINE', label: '백테스트 엔진 (슬리피지·리스크 반영)' },
|
{ value: 'BACKTEST_ENGINE', label: '백테스트 엔진 (슬리피지·리스크 반영)' },
|
||||||
{ value: 'SCAN_SIGNALS', label: '조건 스캔 (봉 종가·DSL 충족)' },
|
{ value: 'SCAN_SIGNALS', label: '조건 스캔 (현재가·DSL 충족)' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -203,11 +203,11 @@ const SECTIONS: SettingSection[] = [
|
|||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
key: 'entryPriceType', type: 'select',
|
key: 'entryPriceType', type: 'select',
|
||||||
opts: [{ value:'CLOSE', label:'종가 (Close)' }, { value:'NEXT_OPEN', label:'다음봉 시가 (Next Open)' }],
|
opts: [{ value:'CURRENT', label:'현재가 (Current)' }, { value:'NEXT_OPEN', label:'다음봉 시가 (Next Open)' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'exitPriceType', type: 'select',
|
key: 'exitPriceType', type: 'select',
|
||||||
opts: [{ value:'CLOSE', label:'종가 (Close)' }, { value:'NEXT_OPEN', label:'다음봉 시가 (Next Open)' }],
|
opts: [{ value:'CURRENT', label:'현재가 (Current)' }, { value:'NEXT_OPEN', label:'다음봉 시가 (Next Open)' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'positionDirection', type: 'select',
|
key: 'positionDirection', type: 'select',
|
||||||
|
|||||||
@@ -233,19 +233,19 @@ export const BacktestSettingsPanel: React.FC<BacktestSettingsPanelProps> = ({
|
|||||||
|
|
||||||
<BtSection title="거래 가격 기준">
|
<BtSection title="거래 가격 기준">
|
||||||
<BtGrid>
|
<BtGrid>
|
||||||
<BtField label="진입 가격 기준" desc="매수 신호 시 진입 가격. 종가 즉시 진입 또는 다음 봉 시가 진입.">
|
<BtField label="진입 가격 기준" desc="매수 신호 시 진입 가격. 현재가(시그널 시점) 또는 다음 봉 시가.">
|
||||||
<select className="stg-select stg-select--wide"
|
<select className="stg-select stg-select--wide"
|
||||||
value={cfg.entryPriceType}
|
value={cfg.entryPriceType === 'CLOSE' ? 'CURRENT' : cfg.entryPriceType}
|
||||||
onChange={e => field('entryPriceType', e.target.value as 'CLOSE' | 'NEXT_OPEN')}>
|
onChange={e => field('entryPriceType', e.target.value as 'CURRENT' | 'NEXT_OPEN')}>
|
||||||
<option value="CLOSE">종가 (Close)</option>
|
<option value="CURRENT">현재가 (Current)</option>
|
||||||
<option value="NEXT_OPEN">다음봉 시가 (Next Open)</option>
|
<option value="NEXT_OPEN">다음봉 시가 (Next Open)</option>
|
||||||
</select>
|
</select>
|
||||||
</BtField>
|
</BtField>
|
||||||
<BtField label="청산 가격 기준" desc="매도 신호 시 청산 가격. 종가 즉시 청산 또는 다음 봉 시가 청산.">
|
<BtField label="청산 가격 기준" desc="매도 신호 시 청산 가격. 현재가(시그널 시점) 또는 다음 봉 시가.">
|
||||||
<select className="stg-select stg-select--wide"
|
<select className="stg-select stg-select--wide"
|
||||||
value={cfg.exitPriceType}
|
value={cfg.exitPriceType === 'CLOSE' ? 'CURRENT' : cfg.exitPriceType}
|
||||||
onChange={e => field('exitPriceType', e.target.value as 'CLOSE' | 'NEXT_OPEN')}>
|
onChange={e => field('exitPriceType', e.target.value as 'CURRENT' | 'NEXT_OPEN')}>
|
||||||
<option value="CLOSE">종가 (Close)</option>
|
<option value="CURRENT">현재가 (Current)</option>
|
||||||
<option value="NEXT_OPEN">다음봉 시가 (Next Open)</option>
|
<option value="NEXT_OPEN">다음봉 시가 (Next Open)</option>
|
||||||
</select>
|
</select>
|
||||||
</BtField>
|
</BtField>
|
||||||
|
|||||||
@@ -1419,8 +1419,8 @@ export interface BacktestSettingsDto {
|
|||||||
commissionType: 'LINEAR' | 'ZERO';
|
commissionType: 'LINEAR' | 'ZERO';
|
||||||
commissionRate: number;
|
commissionRate: number;
|
||||||
slippageRate: number;
|
slippageRate: number;
|
||||||
entryPriceType: 'CLOSE' | 'NEXT_OPEN';
|
entryPriceType: 'CURRENT' | 'CLOSE' | 'NEXT_OPEN';
|
||||||
exitPriceType: 'CLOSE' | 'NEXT_OPEN';
|
exitPriceType: 'CURRENT' | 'CLOSE' | 'NEXT_OPEN';
|
||||||
positionDirection: 'LONG' | 'SHORT' | 'BOTH';
|
positionDirection: 'LONG' | 'SHORT' | 'BOTH';
|
||||||
tradeSizeType: 'CAPITAL_PCT' | 'FIXED_AMOUNT';
|
tradeSizeType: 'CAPITAL_PCT' | 'FIXED_AMOUNT';
|
||||||
tradeSizeValue: number;
|
tradeSizeValue: number;
|
||||||
@@ -1447,8 +1447,8 @@ export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = {
|
|||||||
commissionType: 'LINEAR',
|
commissionType: 'LINEAR',
|
||||||
commissionRate: 0.0015,
|
commissionRate: 0.0015,
|
||||||
slippageRate: 0.0005,
|
slippageRate: 0.0005,
|
||||||
entryPriceType: 'CLOSE',
|
entryPriceType: 'CURRENT',
|
||||||
exitPriceType: 'CLOSE',
|
exitPriceType: 'CURRENT',
|
||||||
positionDirection: 'LONG',
|
positionDirection: 'LONG',
|
||||||
tradeSizeType: 'CAPITAL_PCT',
|
tradeSizeType: 'CAPITAL_PCT',
|
||||||
tradeSizeValue: 100,
|
tradeSizeValue: 100,
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ export function buildStrategyEvaluationReportModel(opts: {
|
|||||||
'분석 기간 전체 캔들 기준으로, 최초 매수 이전 매도·최종 매도 이후 매수 시그널은 제외했습니다. '
|
'분석 기간 전체 캔들 기준으로, 최초 매수 이전 매도·최종 매도 이후 매수 시그널은 제외했습니다. '
|
||||||
+ '매도는 보유 물량 전량 매도로 가정합니다. '
|
+ '매도는 보유 물량 전량 매도로 가정합니다. '
|
||||||
+ (opts.tradeExecutionMode === 'SCAN_SIGNALS'
|
+ (opts.tradeExecutionMode === 'SCAN_SIGNALS'
|
||||||
? '체결가: 조건 스캔(봉 종가·DSL 충족).'
|
? '체결가: 조건 스캔(현재가·DSL 충족).'
|
||||||
: '체결가: 백테스트 설정(슬리피지·손절/익절·진입/청산가) 반영.'),
|
: '체결가: 백테스트 설정(슬리피지·손절/익절·진입/청산가) 반영.'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export const TRADE_EXECUTION_MODE_OPTIONS: {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'SCAN_SIGNALS',
|
value: 'SCAN_SIGNALS',
|
||||||
label: '조건 스캔 (봉 종가·DSL 충족)',
|
label: '조건 스캔 (현재가·DSL 충족)',
|
||||||
desc: '차트 마커·조건 패널과 동일. 봉 종가 기준 이상적 체결.',
|
desc: '차트 마커·조건 패널과 동일. 시그널 시점 현재가 기준 체결.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1130,8 +1130,8 @@ export interface BacktestSettingsDto {
|
|||||||
commissionType: 'LINEAR' | 'ZERO';
|
commissionType: 'LINEAR' | 'ZERO';
|
||||||
commissionRate: number;
|
commissionRate: number;
|
||||||
slippageRate: number;
|
slippageRate: number;
|
||||||
entryPriceType: 'CLOSE' | 'NEXT_OPEN';
|
entryPriceType: 'CURRENT' | 'CLOSE' | 'NEXT_OPEN';
|
||||||
exitPriceType: 'CLOSE' | 'NEXT_OPEN';
|
exitPriceType: 'CURRENT' | 'CLOSE' | 'NEXT_OPEN';
|
||||||
positionDirection: 'LONG' | 'SHORT' | 'BOTH';
|
positionDirection: 'LONG' | 'SHORT' | 'BOTH';
|
||||||
tradeSizeType: 'CAPITAL_PCT' | 'FIXED_AMOUNT';
|
tradeSizeType: 'CAPITAL_PCT' | 'FIXED_AMOUNT';
|
||||||
tradeSizeValue: number;
|
tradeSizeValue: number;
|
||||||
@@ -1154,8 +1154,8 @@ export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = {
|
|||||||
commissionType: 'LINEAR',
|
commissionType: 'LINEAR',
|
||||||
commissionRate: 0.0015,
|
commissionRate: 0.0015,
|
||||||
slippageRate: 0.0005,
|
slippageRate: 0.0005,
|
||||||
entryPriceType: 'CLOSE',
|
entryPriceType: 'CURRENT',
|
||||||
exitPriceType: 'CLOSE',
|
exitPriceType: 'CURRENT',
|
||||||
positionDirection: 'LONG',
|
positionDirection: 'LONG',
|
||||||
tradeSizeType: 'CAPITAL_PCT',
|
tradeSizeType: 'CAPITAL_PCT',
|
||||||
tradeSizeValue: 100,
|
tradeSizeValue: 100,
|
||||||
|
|||||||
Reference in New Issue
Block a user