전략평가 개선
This commit is contained in:
@@ -26,4 +26,30 @@ public class TrendSearchSchedulerConfig {
|
||||
ex.initialize();
|
||||
return ex;
|
||||
}
|
||||
|
||||
/**
|
||||
* [항목4] BarBuilder Phase 2 전략 평가 전용 스레드 풀.
|
||||
*
|
||||
* <p>업비트 WebSocket 수신 스레드가 분봉 마감 시 전략 평가 연산에 블로킹되지 않도록
|
||||
* Phase 2(전략 평가 트리거)를 별도 스레드로 분리한다.
|
||||
*
|
||||
* <p>소규모(20~30인) 환경 기준:
|
||||
* <ul>
|
||||
* <li>corePoolSize=2: 여러 마켓의 분봉이 동시 마감되어도 2개 스레드로 병렬 처리</li>
|
||||
* <li>maxPoolSize=4: 순간 피크(예: 매시 00분 1m/5m/15m/1h 동시 마감) 대응</li>
|
||||
* <li>queueCapacity=32: 밀린 평가 작업을 드롭 없이 큐잉</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Bean(name = "barCloseEvalExecutor")
|
||||
public Executor barCloseEvalExecutor() {
|
||||
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
|
||||
ex.setThreadNamePrefix("bar-close-eval-");
|
||||
ex.setCorePoolSize(2);
|
||||
ex.setMaxPoolSize(4);
|
||||
ex.setQueueCapacity(32);
|
||||
ex.setWaitForTasksToCompleteOnShutdown(true);
|
||||
ex.setAwaitTerminationSeconds(60);
|
||||
ex.initialize();
|
||||
return ex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.goldenchart.entity.GcLiveStrategySettings;
|
||||
import com.goldenchart.entity.GcStrategy;
|
||||
import com.goldenchart.entity.GcTradeSignal;
|
||||
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
||||
import com.goldenchart.repository.GcPaperAccountRepository;
|
||||
import com.goldenchart.repository.GcPaperPositionRepository;
|
||||
import com.goldenchart.repository.GcStrategyRepository;
|
||||
import com.goldenchart.repository.GcTradeSignalRepository;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
@@ -15,6 +17,7 @@ import org.ta4j.core.Rule;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -60,6 +63,8 @@ public class LiveStrategyEvaluator {
|
||||
private final GcLiveStrategySettingsRepository settingsRepo;
|
||||
private final GcStrategyRepository strategyRepo;
|
||||
private final GcTradeSignalRepository tradeSignalRepo;
|
||||
private final GcPaperAccountRepository paperAccountRepo;
|
||||
private final GcPaperPositionRepository paperPositionRepo;
|
||||
private final Ta4jStorage ta4jStorage;
|
||||
private final IndicatorSettingsService indicatorSettingsService;
|
||||
private final StrategySignalDeterminer determiner;
|
||||
@@ -308,7 +313,7 @@ public class LiveStrategyEvaluator {
|
||||
triggerRulesCache.put(cacheKey, rules);
|
||||
}
|
||||
|
||||
return applySignalLogic(rules, market, candleType, strategyId, positionMode, index);
|
||||
return applySignalLogic(rules, market, candleType, strategyId, positionMode, index, deviceId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -321,12 +326,13 @@ public class LiveStrategyEvaluator {
|
||||
String deviceId, Long userId, boolean useConfirmedOnly) {
|
||||
TriggerRules rules = buildTriggerRules(market, candleType, strategyId, deviceId, userId, useConfirmedOnly);
|
||||
if (rules == null) return "NONE";
|
||||
return applySignalLogic(rules, market, candleType, strategyId, positionMode, index);
|
||||
return applySignalLogic(rules, market, candleType, strategyId, positionMode, index, deviceId, userId);
|
||||
}
|
||||
|
||||
/** Rule 판정 + TradingRecord 갱신 공통 로직 */
|
||||
private String applySignalLogic(TriggerRules rules, String market, String candleType,
|
||||
long strategyId, String positionMode, int index) {
|
||||
long strategyId, String positionMode, int index,
|
||||
String deviceId, Long userId) {
|
||||
String cacheKey = market + ":" + candleType + ":" + strategyId;
|
||||
try {
|
||||
String mode = positionMode != null ? positionMode : "LONG_ONLY";
|
||||
@@ -338,7 +344,7 @@ public class LiveStrategyEvaluator {
|
||||
|
||||
BaseTradingRecord record = tradingRecordCache.computeIfAbsent(
|
||||
cacheKey, k -> new BaseTradingRecord());
|
||||
boolean isOpen = resolvePositionOpen(cacheKey, market, strategyId);
|
||||
boolean isOpen = resolvePositionOpen(cacheKey, market, strategyId, deviceId, userId);
|
||||
String signal = determiner.determineSignalFromRules(
|
||||
rules.entryRule(), rules.exitRule(), record, index, "LONG_ONLY");
|
||||
|
||||
@@ -448,18 +454,73 @@ public class LiveStrategyEvaluator {
|
||||
* <li>최신 시그널 = SELL 또는 없음 → 포지션 없음</li>
|
||||
* </ul>
|
||||
*/
|
||||
private boolean resolvePositionOpen(String cacheKey, String market, long strategyId) {
|
||||
/**
|
||||
* LONG_ONLY 포지션 오픈 여부 복원 — gc_trade_signal + gc_paper_position 교차 검증.
|
||||
*
|
||||
* <p>[항목3 보완] 서버 재시작 시 gc_trade_signal DB 의 최신 레코드만 보면 "마지막 신호=BUY"라서
|
||||
* 포지션이 열려 있다고 판단할 수 있지만, 서버 다운 중 사용자가 수동으로 물량을 전량 매도했다면
|
||||
* 실제 가상 잔고(gc_paper_position.quantity)는 0 이다. 이 경우 gc_trade_signal 에 SELL 기록이
|
||||
* 없어도 포지션이 없다고 바르게 판정해야 한다.
|
||||
*
|
||||
* <p>로직 순서:
|
||||
* <ol>
|
||||
* <li>캐시 히트 → 즉시 반환</li>
|
||||
* <li>gc_trade_signal 최신 레코드 조회 → 마지막 신호가 BUY 이면 "열림" 후보</li>
|
||||
* <li>"열림" 후보인 경우에만 gc_paper_position.quantity 교차 검증:
|
||||
* quantity == 0 → 수동 청산된 것으로 판단, isOpen = false 로 보정</li>
|
||||
* </ol>
|
||||
*/
|
||||
private boolean resolvePositionOpen(String cacheKey, String market, long strategyId,
|
||||
String deviceId, Long userId) {
|
||||
Boolean cached = positionOpenCache.get(cacheKey);
|
||||
if (cached != null) return cached;
|
||||
|
||||
// DB 복원: 해당 마켓×전략의 최신 시그널
|
||||
// Step 1: gc_trade_signal 최신 레코드로 1차 판정
|
||||
Optional<GcTradeSignal> latest =
|
||||
tradeSignalRepo.findFirstByMarketAndStrategyIdOrderByCreatedAtDesc(market, strategyId);
|
||||
boolean isOpen = latest.map(s -> "BUY".equals(s.getSignalType())).orElse(false);
|
||||
|
||||
// Step 2: "열림" 후보인 경우만 gc_paper_position 교차 검증
|
||||
if (isOpen) {
|
||||
try {
|
||||
Long accountId = resolveAccountId(userId, deviceId);
|
||||
if (accountId != null) {
|
||||
// 심볼 형식: gc_paper_position 은 market 값 그대로 저장 (e.g. "KRW-BTC")
|
||||
boolean hasPosition = paperPositionRepo
|
||||
.findByAccountIdAndSymbol(accountId, market)
|
||||
.map(p -> p.getQuantity() != null
|
||||
&& p.getQuantity().compareTo(BigDecimal.ZERO) > 0)
|
||||
.orElse(false);
|
||||
if (!hasPosition) {
|
||||
isOpen = false;
|
||||
log.info("[Evaluator] 포지션 교차검증: gc_trade_signal=BUY 지만 " +
|
||||
"gc_paper_position.quantity=0 → 수동청산 감지, key={} → CLOSED", cacheKey);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[Evaluator] 포지션 교차검증 실패 key={}: {} — signal-only 판정 유지", cacheKey, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
positionOpenCache.put(cacheKey, isOpen);
|
||||
if (isOpen) {
|
||||
log.info("[Evaluator] 포지션 상태 복원 (DB) key={} → OPEN (BUY 포지션 유지)", cacheKey);
|
||||
log.info("[Evaluator] 포지션 상태 복원 (DB+position 교차검증) key={} → OPEN", cacheKey);
|
||||
}
|
||||
return isOpen;
|
||||
}
|
||||
|
||||
/** userId 또는 deviceId 로 가상계좌 ID 조회 */
|
||||
private Long resolveAccountId(Long userId, String deviceId) {
|
||||
if (userId != null) {
|
||||
return paperAccountRepo.findByUserId(userId)
|
||||
.map(a -> a.getId())
|
||||
.orElse(null);
|
||||
}
|
||||
if (deviceId != null && !deviceId.isBlank()) {
|
||||
return paperAccountRepo.findByDeviceId(deviceId)
|
||||
.map(a -> a.getId())
|
||||
.orElse(null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,8 +294,26 @@ public class StrategyDslToTa4jAdapter {
|
||||
java.time.Instant primaryEndTime = primarySeries.getBar(index).getEndTime();
|
||||
evalIndex = findLastIndexAtOrBefore(branchSeries, primaryEndTime);
|
||||
} else if (useConfirmedOnly) {
|
||||
// CANDLE_CLOSE: 마지막 확정봉(provisional 제외)
|
||||
evalIndex = Math.max(branchSeries.getBeginIndex(), branchSeries.getEndIndex() - 1);
|
||||
// CANDLE_CLOSE — 타임스탬프 기반 확정봉 판정 (백테스트 룩업과 동일 로직)
|
||||
//
|
||||
// [버그 수정] 단순 getEndIndex()-1 을 사용하면 경계 시점(예: 5m=10:00, 1h=10:00 동시 마감)에
|
||||
// 1h 봉이 이미 완전히 확정됐음에도 getEndIndex()-1 이 한 봉 이전(08:00-09:00)을 가리켜
|
||||
// 백테스트와 1봉 차이가 발생했다. primaryEndTime 과 branchSeries 마지막 봉의 endTime 을
|
||||
// 비교해 진짜 provisional 인지 이미 확정된 봉인지 구분한다.
|
||||
//
|
||||
// branchSeries.lastBar.endTime <= primary.endTime
|
||||
// → 상위봉이 기본봉보다 이른 시점에 이미 마감된 경우 (=확정봉) → getEndIndex() 사용
|
||||
// branchSeries.lastBar.endTime > primary.endTime
|
||||
// → 상위봉 기간이 아직 열려 있는 경우 (=provisional) → getEndIndex()-1 사용
|
||||
java.time.Instant primaryEndTime = primarySeries.getBar(index).getEndTime();
|
||||
java.time.Instant branchLastEndTime = branchSeries.getLastBar().getEndTime();
|
||||
if (!branchLastEndTime.isAfter(primaryEndTime)) {
|
||||
// 상위봉이 기본봉 기준으로 이미 확정된 봉 → getEndIndex() 직접 사용
|
||||
evalIndex = branchSeries.getEndIndex();
|
||||
} else {
|
||||
// 상위봉이 아직 형성 중(provisional) → 직전 확정봉 사용
|
||||
evalIndex = Math.max(branchSeries.getBeginIndex(), branchSeries.getEndIndex() - 1);
|
||||
}
|
||||
} else {
|
||||
// REALTIME_TICK: 현재 진행 중인 봉 포함
|
||||
evalIndex = branchSeries.getEndIndex();
|
||||
@@ -567,7 +585,8 @@ public class StrategyDslToTa4jAdapter {
|
||||
try {
|
||||
Indicator<Num> left = resolveField(leftField, indType, indParams, leftSeries, condPeriod, leftPeriod, cond, ctx);
|
||||
Indicator<Num> right = resolveField(rightField, indType, indParams, rightSeries, condPeriod, rightPeriod, cond, ctx);
|
||||
return new CrossTimeframeCompositeRule(condType, left, right, trigger, leftSeries, rightSeries, ctx.isBacktest());
|
||||
return new CrossTimeframeCompositeRule(condType, left, right, trigger, leftSeries, rightSeries,
|
||||
ctx.isBacktest(), ctx.useConfirmedOnly());
|
||||
} catch (Exception e) {
|
||||
log.warn("[Adapter] 복합 교차시간봉 빌드 실패 ind={} cond={}: {}",
|
||||
indType, condType, e.getMessage());
|
||||
@@ -575,12 +594,39 @@ public class StrategyDslToTa4jAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
private static int evalIndex(BarSeries trigger, BarSeries branch, int triggerIndex, boolean isBacktest) {
|
||||
/**
|
||||
* [버그 수정] 라이브 CANDLE_CLOSE 시 상위봉 인덱스 결정 — CrossSeriesRule 과 동일 타임스탬프 비교 로직 적용.
|
||||
*
|
||||
* <p>기존 isBacktest=false 시 단순 {@code branch.getEndIndex()} 를 반환했는데,
|
||||
* 이는 경계 시점(예: 5m=10:00, 1h=10:00 동시 마감)에 올바른 1h 봉을 가리키지만
|
||||
* useConfirmedOnly=true 일 때 provisional 여부를 판단하지 않아 불일치 가능성이 있었다.
|
||||
*
|
||||
* <p>개선: isBacktest 여부와 무관하게 타임스탬프 비교로 통일한다.
|
||||
* <ul>
|
||||
* <li>backtest 또는 branch.lastEndTime ≤ trigger.endTime → 타임스탬프 기반 정확한 인덱스</li>
|
||||
* <li>라이브 REALTIME_TICK (useConfirmedOnly=false) 이고 branch 가 아직 진행 중 → getEndIndex()</li>
|
||||
* </ul>
|
||||
*/
|
||||
private static int evalIndex(BarSeries trigger, BarSeries branch, int triggerIndex,
|
||||
boolean isBacktest, boolean useConfirmedOnly) {
|
||||
if (trigger == branch) return triggerIndex;
|
||||
java.time.Instant triggerEndTime = trigger.getBar(triggerIndex).getEndTime();
|
||||
|
||||
if (isBacktest) {
|
||||
java.time.Instant t = trigger.getBar(triggerIndex).getEndTime();
|
||||
return CrossSeriesRule.findLastIndexAtOrBefore(branch, t);
|
||||
return CrossSeriesRule.findLastIndexAtOrBefore(branch, triggerEndTime);
|
||||
}
|
||||
|
||||
if (useConfirmedOnly) {
|
||||
// CANDLE_CLOSE — CrossSeriesRule 과 동일한 타임스탬프 비교 로직
|
||||
java.time.Instant branchLastEndTime = branch.getLastBar().getEndTime();
|
||||
if (!branchLastEndTime.isAfter(triggerEndTime)) {
|
||||
return branch.getEndIndex();
|
||||
} else {
|
||||
return Math.max(branch.getBeginIndex(), branch.getEndIndex() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// REALTIME_TICK: 현재 진행 중인 봉 포함
|
||||
int end = branch.getEndIndex();
|
||||
return end >= 0 ? end : 0;
|
||||
}
|
||||
@@ -593,6 +639,7 @@ public class StrategyDslToTa4jAdapter {
|
||||
private final BarSeries leftSeries;
|
||||
private final BarSeries rightSeries;
|
||||
private final boolean isBacktest;
|
||||
private final boolean useConfirmedOnly;
|
||||
|
||||
CrossTimeframeCompositeRule(String condType,
|
||||
Indicator<Num> left,
|
||||
@@ -600,7 +647,8 @@ public class StrategyDslToTa4jAdapter {
|
||||
BarSeries triggerSeries,
|
||||
BarSeries leftSeries,
|
||||
BarSeries rightSeries,
|
||||
boolean isBacktest) {
|
||||
boolean isBacktest,
|
||||
boolean useConfirmedOnly) {
|
||||
this.condType = condType;
|
||||
this.left = left;
|
||||
this.right = right;
|
||||
@@ -608,12 +656,13 @@ public class StrategyDslToTa4jAdapter {
|
||||
this.leftSeries = leftSeries;
|
||||
this.rightSeries = rightSeries;
|
||||
this.isBacktest = isBacktest;
|
||||
this.useConfirmedOnly = useConfirmedOnly;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
|
||||
int li = evalIndex(triggerSeries, leftSeries, index, isBacktest);
|
||||
int ri = evalIndex(triggerSeries, rightSeries, index, isBacktest);
|
||||
int li = evalIndex(triggerSeries, leftSeries, index, isBacktest, useConfirmedOnly);
|
||||
int ri = evalIndex(triggerSeries, rightSeries, index, isBacktest, useConfirmedOnly);
|
||||
if (li < 1 || ri < 1) return false;
|
||||
|
||||
Num l0 = left.getValue(li - 1);
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.goldenchart.service.BarCloseStrategyEvaluationService;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.ta4j.core.Bar;
|
||||
import org.ta4j.core.BarSeries;
|
||||
@@ -18,6 +19,7 @@ import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* 업비트 WebSocket 틱(Trade) 데이터를 수신하여 분봉 단위 캔들을 조립하는 빌더.
|
||||
@@ -39,6 +41,14 @@ public class BarBuilder {
|
||||
private final BarCloseStrategyEvaluationService barCloseEvaluation;
|
||||
private final com.goldenchart.service.PaperOrderMatcherService paperOrderMatcher;
|
||||
|
||||
/**
|
||||
* [항목4] Phase 2 비동기 실행용 스레드 풀.
|
||||
* 웹소켓 수신 스레드가 전략 평가 연산에 블로킹되지 않도록 분리한다.
|
||||
*/
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
@Qualifier("barCloseEvalExecutor")
|
||||
private Executor barCloseEvalExecutor;
|
||||
|
||||
/** 상위 집계 타임프레임 (분 단위 집계 주기: 3, 5, 10, 15, 30, 60, 240, 1440) */
|
||||
private static final int[] UPPER_MINUTES = {3, 5, 10, 15, 30, 60, 240, 10080, 1440};
|
||||
private static final String[] UPPER_TYPES = {"3m", "5m", "10m", "15m", "30m", "1h", "4h", "1w", "1d"};
|
||||
@@ -150,18 +160,34 @@ public class BarBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: 모든 봉이 확정된 후 전략 평가 트리거 ─────────────────────
|
||||
// ── Phase 2: 모든 봉이 확정된 후 전략 평가 트리거 (비동기) ──────────────
|
||||
//
|
||||
// [항목4] 웹소켓 수신 스레드(업비트 연결 유지 담당)가 전략 평가 연산 때문에 블로킹되면
|
||||
// 다음 분봉 시작 틱을 제때 수신하지 못할 수 있다. Phase 1 완료 후 Phase 2 를
|
||||
// 전용 스레드 풀(barCloseEvalExecutor)에 제출하여 WebSocket 파이프라인을 즉시 반환한다.
|
||||
//
|
||||
// 스레드 안전성: Ta4jStorage·positionOpenCache 는 ConcurrentHashMap, maturedIdx 는
|
||||
// Phase 1 에서 이미 확정된 값이므로 비동기 실행 중 Phase 1 이 재실행되어 인덱스가 바뀌어도
|
||||
// 올바른 봉을 참조한다.
|
||||
|
||||
if (barCloseEvaluation.shouldEvaluateOnClose(market, "1m")) {
|
||||
BarSeries series1m = ta4jStorage.getOrCreate(market, "1m");
|
||||
barCloseEvaluation.onMaturedBarClose(market, "1m", series1m.getEndIndex(), bar);
|
||||
}
|
||||
|
||||
for (UpperCloseInfo info : closedUppers) {
|
||||
if (barCloseEvaluation.shouldEvaluateOnClose(market, info.type())) {
|
||||
barCloseEvaluation.onMaturedBarClose(market, info.type(), info.maturedIdx(), info.bar());
|
||||
final Bar confirmedBar = bar;
|
||||
final java.util.List<UpperCloseInfo> closedUppersSnap
|
||||
= java.util.Collections.unmodifiableList(closedUppers);
|
||||
barCloseEvalExecutor.execute(() -> {
|
||||
try {
|
||||
if (barCloseEvaluation.shouldEvaluateOnClose(market, "1m")) {
|
||||
BarSeries series1m = ta4jStorage.getOrCreate(market, "1m");
|
||||
barCloseEvaluation.onMaturedBarClose(market, "1m", series1m.getEndIndex(), confirmedBar);
|
||||
}
|
||||
for (UpperCloseInfo info : closedUppersSnap) {
|
||||
if (barCloseEvaluation.shouldEvaluateOnClose(market, info.type())) {
|
||||
barCloseEvaluation.onMaturedBarClose(market, info.type(), info.maturedIdx(), info.bar());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[BarBuilder] Phase 2 전략 평가 오류 market={}: {}", market, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Phase 1 에서 마감된 상위봉 정보를 Phase 2 로 전달하는 내부 레코드 */
|
||||
|
||||
Reference in New Issue
Block a user