전략평가 로직 개선

This commit is contained in:
Macbook
2026-06-05 10:59:23 +09:00
parent 5278177bbb
commit 0aaf4ac19c
14 changed files with 441 additions and 60 deletions
@@ -95,63 +95,78 @@ public class BarBuilder {
// ── 내부 처리 ──────────────────────────────────────────────────────────────
/** 완성된 1분봉을 Ta4jStorage 에 저장하고 상위 타임프레임에 전파 */
/**
* 완성된 1분봉을 Ta4jStorage 에 저장하고 상위 타임프레임에 전파한다.
*
* <p>[항목2] 2-Phase 처리 — 멀티 타임프레임 레이스 컨디션 해소:
* <ul>
* <li><b>Phase 1</b>: 1m 봉과 모든 상위봉을 Ta4jStorage 에 먼저 확정한다.</li>
* <li><b>Phase 2</b>: 모든 저장이 완료된 후에야 전략 평가를 트리거한다.</li>
* </ul>
* 기존 방식(1m 저장 → 1m 전략 평가 → 상위봉 저장 → 상위봉 전략 평가)에서는
* 1m 전략이 1h CrossSeriesRule 을 참조할 때 1h 봉이 아직 확정 전이어서
* 잘못된 인덱스를 바라보는 레이스 컨디션이 있었다.
*/
private void commitBar(String market, PartialBar partial) {
Duration d1m = Duration.ofMinutes(1);
ZonedDateTime endTime = partial.barStart.plus(d1m);
// Ta4j Bar 생성
Bar bar = buildTa4jBar(partial, d1m, endTime);
// 1분봉 Ta4jStorage 저장
// ── Phase 1: 모든 타임프레임 스토리지 업데이트 ────────────────────────
ta4jStorage.addBar(market, "1m", bar);
log.debug("[BarBuilder] 1m Bar 확정: {} @ {}", market, endTime);
// 마감된 상위봉 정보(타입, maturedIndex, bar) — Phase 2 에서 사용
java.util.List<UpperCloseInfo> closedUppers = new java.util.ArrayList<>();
for (int i = 0; i < UPPER_MINUTES.length; i++) {
int minutes = UPPER_MINUTES[i];
String type = UPPER_TYPES[i];
ZonedDateTime upperStart = alignToInterval(partial.barStart, minutes);
ZonedDateTime upperEnd = upperStart.plusMinutes(minutes);
BarSeries upperSeries = ta4jStorage.getOrCreate(market, type);
if (upperSeries.isEmpty()) {
Bar upperBar = buildTa4jBar(partial, Duration.ofMinutes(minutes), upperEnd);
ta4jStorage.addBar(market, type, upperBar);
} else {
Instant lastUpperEndInstant = upperSeries.getLastBar().getEndTime();
Instant upperEndInstant = upperEnd.toInstant();
if (lastUpperEndInstant.equals(upperEndInstant)) {
ta4jStorage.updateLastBar(market, type, partial.close, partial.volume);
} else if (lastUpperEndInstant.isBefore(upperEndInstant)) {
// 상위봉 마감 → 새 봉 추가
Bar upperBar = buildTa4jBar(partial, Duration.ofMinutes(minutes), upperEnd);
ta4jStorage.addBar(market, type, upperBar);
BarSeries upperAfter = ta4jStorage.getOrCreate(market, type);
int maturedIdx = Math.max(upperAfter.getBeginIndex(), upperAfter.getEndIndex() - 1);
closedUppers.add(new UpperCloseInfo(type, maturedIdx, upperAfter.getBar(maturedIdx)));
} else {
log.warn("[BarBuilder] 상위봉 endTime 역행 {} {} last={} expected={} — 갱신만 수행",
market, type, lastUpperEndInstant, upperEndInstant);
ta4jStorage.updateLastBar(market, type, partial.close, partial.volume);
}
}
}
// ── Phase 2: 모든 봉이 확정된 후 전략 평가 트리거 ─────────────────────
if (barCloseEvaluation.shouldEvaluateOnClose(market, "1m")) {
BarSeries series1m = ta4jStorage.getOrCreate(market, "1m");
barCloseEvaluation.onMaturedBarClose(market, "1m", series1m.getEndIndex(), bar);
}
// 상위 타임프레임 전파
for (int i = 0; i < UPPER_MINUTES.length; i++) {
int minutes = UPPER_MINUTES[i];
String type = UPPER_TYPES[i];
// 해당 상위 봉의 시작 시간 계산
ZonedDateTime upperStart = alignToInterval(partial.barStart, minutes);
ZonedDateTime upperEnd = upperStart.plusMinutes(minutes);
BarSeries upperSeries = ta4jStorage.getOrCreate(market, type);
if (upperSeries.isEmpty()) {
// 새 상위봉 열기
Bar upperBar = buildTa4jBar(partial, Duration.ofMinutes(minutes), upperEnd);
ta4jStorage.addBar(market, type, upperBar);
} else {
Bar lastUpper = upperSeries.getLastBar();
// Ta4j 0.22: getEndTime() → Instant
Instant lastUpperEndInstant = lastUpper.getEndTime();
Instant upperEndInstant = upperEnd.toInstant();
if (lastUpperEndInstant.equals(upperEndInstant)) {
// 동일 구간 → 가격 갱신
ta4jStorage.updateLastBar(market, type, partial.close, partial.volume);
} else if (lastUpperEndInstant.isBefore(upperEndInstant)) {
// 다음 구간 → 새 Bar 추가 (이전 상위봉 마감)
Bar upperBar = buildTa4jBar(partial, Duration.ofMinutes(minutes), upperEnd);
ta4jStorage.addBar(market, type, upperBar);
BarSeries upperAfter = ta4jStorage.getOrCreate(market, type);
if (barCloseEvaluation.shouldEvaluateOnClose(market, type)) {
int maturedUpper = Math.max(upperAfter.getBeginIndex(), upperAfter.getEndIndex() - 1);
Bar maturedUpperBar = upperAfter.getBar(maturedUpper);
barCloseEvaluation.onMaturedBarClose(market, type, maturedUpper, maturedUpperBar);
}
} else {
log.warn("[BarBuilder] 상위봉 endTime 역행 {} {} last={} expected={} — 갱신만 수행",
market, type, lastUpperEndInstant, upperEndInstant);
ta4jStorage.updateLastBar(market, type, partial.close, partial.volume);
}
}
for (UpperCloseInfo info : closedUppers) {
if (barCloseEvaluation.shouldEvaluateOnClose(market, info.type())) {
barCloseEvaluation.onMaturedBarClose(market, info.type(), info.maturedIdx(), info.bar());
}
}
}
/** Phase 1 에서 마감된 상위봉 정보를 Phase 2 로 전달하는 내부 레코드 */
private record UpperCloseInfo(String type, int maturedIdx, Bar bar) {}
/** 현재 진행 중인 1분봉을 STOMP 로 발행 (실시간 틱 렌더링용, 시그널 없음). */
private void publishCurrentBar(String market) {
PartialBar p = partialBars.get(market);