알림팝업 수정, 전략평가 로직 개선

This commit is contained in:
Macbook
2026-06-05 10:32:40 +09:00
parent 238dd0cebe
commit 5278177bbb
9 changed files with 484 additions and 116 deletions
@@ -111,18 +111,44 @@ public class StrategyDslToTa4jAdapter {
Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual,
String market,
Ta4jStorage storage
Ta4jStorage storage,
/**
* CANDLE_CLOSE 평가 시 true — CrossSeriesRule 이 상위봉의 마지막 확정봉
* (endIndex - 1)을 사용하도록 강제한다. provisional 봉 오염을 방지.
*/
boolean useConfirmedOnly,
/**
* 백테스트용 사전 집계 시리즈 맵 candleType→BarSeries.
* null 이 아니고 비어 있지 않으면 백테스트 모드로 동작하며
* CrossSeriesRule 에서 타임스탬프 기반 룩업을 수행한다.
*/
Map<String, BarSeries> seriesOverrides
) {
/** visual 미전달 호환 */
/** visual 미전달 호환 (레거시) */
public RuleBuildContext(BarSeries primarySeries,
Map<String, Map<String, Object>> indicatorParams,
String market,
Ta4jStorage storage) {
this(primarySeries, indicatorParams, Map.of(), market, storage);
this(primarySeries, indicatorParams, Map.of(), market, storage, false, Map.of());
}
/** primarySeries 만 교체하고 나머지 필드 복사 (하위 컨텍스트 생성용) */
public RuleBuildContext withPrimary(BarSeries newPrimary) {
return new RuleBuildContext(newPrimary, indicatorParams, indicatorVisual,
market, storage, useConfirmedOnly, seriesOverrides);
}
/** 사용 중인 시리즈 오버라이드가 있으면 백테스트 모드 */
public boolean isBacktest() {
return seriesOverrides != null && !seriesOverrides.isEmpty();
}
BarSeries resolveSeries(String candleType) {
String ct = LiveStrategyTimeframeService.normalize(candleType);
// 백테스트용 사전 집계 시리즈 우선 검색
if (seriesOverrides != null && seriesOverrides.containsKey(ct)) {
return seriesOverrides.get(ct);
}
if (storage != null && market != null && !market.isBlank()
&& storage.exists(market, ct)) {
return storage.getOrCreate(market, ct);
@@ -133,20 +159,41 @@ public class StrategyDslToTa4jAdapter {
public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams) {
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null));
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false, Map.of()));
}
public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams,
String market, Ta4jStorage storage) {
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), market, storage));
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), market, storage, false, Map.of()));
}
public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual,
String market, Ta4jStorage storage) {
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage));
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, false, Map.of()));
}
/**
* 백테스트 전용: 사전 집계된 상위봉 시리즈 맵을 함께 전달.
* CrossSeriesRule 이 타임스탬프 기반 룩업을 수행하여 라이브와 동일한 멀티 TF 평가를 보장한다.
*/
public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams,
Map<String, BarSeries> seriesOverrides) {
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false,
seriesOverrides != null ? seriesOverrides : Map.of()));
}
/**
* CANDLE_CLOSE 전용: useConfirmedOnly=true 로 상위봉 확정봉 인덱스를 사용한다.
*/
public Rule toRuleOnCandleClose(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual,
String market, Ta4jStorage storage) {
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, true, Map.of()));
}
public Rule toRule(JsonNode node, RuleBuildContext ctx) {
@@ -198,35 +245,82 @@ public class StrategyDslToTa4jAdapter {
private Rule buildTimeframeRule(JsonNode node, RuleBuildContext ctx) {
String ct = node.path("candleType").asText("1m");
BarSeries branchSeries = ctx.resolveSeries(ct);
RuleBuildContext branchCtx = new RuleBuildContext(
branchSeries, ctx.indicatorParams(), ctx.indicatorVisual(), ctx.market(), ctx.storage());
RuleBuildContext branchCtx = ctx.withPrimary(branchSeries);
List<Rule> rules = childRules(node, branchCtx);
if (rules.isEmpty()) return new BooleanRule(false);
Rule inner = rules.get(0);
if (branchSeries == ctx.primarySeries()) return inner;
return new CrossSeriesRule(inner, branchSeries, ctx.primarySeries());
return new CrossSeriesRule(inner, branchSeries, ctx.primarySeries(),
ctx.useConfirmedOnly(), ctx.isBacktest());
}
/** 다른 시간봉 시리즈로 빌드된 Rule — 트리거 시리즈 인덱스에서 해당 분봉 최신봉으로 평가 */
/**
* 다른 시간봉 시리즈로 빌드된 Rule.
*
* <ul>
* <li><b>백테스트 모드</b>({@code isBacktest=true}): 기본봉 endTime ≤ 기준으로 상위봉 인덱스를
* 이진탐색하여 정확히 대응하는 확정봉을 사용한다. 라이브와 동일한 멀티 TF 시그널 보장.</li>
* <li><b>CANDLE_CLOSE 모드</b>({@code useConfirmedOnly=true}): 상위봉의 마지막 확정봉
* (endIndex - 1)을 사용한다. 진행 중인 provisional 봉을 참조하지 않음.</li>
* <li><b>REALTIME_TICK 모드</b>(둘 다 false): 상위봉의 {@code getEndIndex()}(현재 봉 포함)
* 를 사용한다. 실시간 지표값 반영.</li>
* </ul>
*/
private static final class CrossSeriesRule implements Rule {
private final Rule inner;
private final BarSeries branchSeries;
private final BarSeries triggerSeries;
private final BarSeries primarySeries;
private final boolean useConfirmedOnly;
private final boolean isBacktest;
CrossSeriesRule(Rule inner, BarSeries branchSeries, BarSeries triggerSeries) {
CrossSeriesRule(Rule inner, BarSeries branchSeries, BarSeries primarySeries,
boolean useConfirmedOnly, boolean isBacktest) {
this.inner = inner;
this.branchSeries = branchSeries;
this.triggerSeries = triggerSeries;
this.primarySeries = primarySeries;
this.useConfirmedOnly = useConfirmedOnly;
this.isBacktest = isBacktest;
}
@Override
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
int evalIndex = branchSeries == triggerSeries
? index
: branchSeries.getEndIndex();
if (evalIndex < 0 || branchSeries.getBarCount() == 0) return false;
if (branchSeries.getBarCount() == 0) return false;
int evalIndex;
if (branchSeries == primarySeries) {
evalIndex = index;
} else if (isBacktest) {
// 백테스트: 기본봉 endTime 이하의 마지막 상위봉 인덱스 (타임스탬프 매핑)
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);
} else {
// REALTIME_TICK: 현재 진행 중인 봉 포함
evalIndex = branchSeries.getEndIndex();
}
if (evalIndex < 0) return false;
return inner.isSatisfied(evalIndex, tradingRecord);
}
/** 이진탐색: endTime <= targetTime 인 마지막 봉 인덱스 반환 (-1 if none) */
static int findLastIndexAtOrBefore(BarSeries series, java.time.Instant targetTime) {
int lo = series.getBeginIndex();
int hi = series.getEndIndex();
int result = -1;
while (lo <= hi) {
int mid = (lo + hi) / 2;
if (!series.getBar(mid).getEndTime().isAfter(targetTime)) {
result = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return result;
}
}
// ── AND / OR / NOT ────────────────────────────────────────────────────────
@@ -264,22 +358,22 @@ public class StrategyDslToTa4jAdapter {
private Rule buildAndRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) {
return buildAndRule(node, new RuleBuildContext(series, p, Map.of(), null, null));
return buildAndRule(node, new RuleBuildContext(series, p, Map.of(), null, null, false, Map.of()));
}
private Rule buildOrRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) {
return buildOrRule(node, new RuleBuildContext(series, p, Map.of(), null, null));
return buildOrRule(node, new RuleBuildContext(series, p, Map.of(), null, null, false, Map.of()));
}
private Rule buildNotRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) {
return buildNotRule(node, new RuleBuildContext(series, p, Map.of(), null, null));
return buildNotRule(node, new RuleBuildContext(series, p, Map.of(), null, null, false, Map.of()));
}
private List<Rule> childRules(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) {
return childRules(node, new RuleBuildContext(series, p, Map.of(), null, null));
return childRules(node, new RuleBuildContext(series, p, Map.of(), null, null, false, Map.of()));
}
// ── CONDITION ─────────────────────────────────────────────────────────────
@@ -437,7 +531,10 @@ public class StrategyDslToTa4jAdapter {
/** 복합지표 — leftCandleType / rightCandleType 이 서로 다를 때 교차 시간봉 평가 */
private boolean needsCrossTimeframeComposite(JsonNode cond, RuleBuildContext ctx) {
if (!cond.path("composite").asBoolean(false)) return false;
if (ctx.storage() == null || ctx.market() == null || ctx.market().isBlank()) return false;
// 라이브: storage + market 필요 / 백테스트: seriesOverrides 맵으로 대체
boolean hasSource = (ctx.storage() != null && ctx.market() != null && !ctx.market().isBlank())
|| ctx.isBacktest();
if (!hasSource) return false;
String leftCt = LiveStrategyTimeframeService.normalize(
cond.path("leftCandleType").asText("1m"));
String rightCt = LiveStrategyTimeframeService.normalize(
@@ -470,7 +567,7 @@ 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);
return new CrossTimeframeCompositeRule(condType, left, right, trigger, leftSeries, rightSeries, ctx.isBacktest());
} catch (Exception e) {
log.warn("[Adapter] 복합 교차시간봉 빌드 실패 ind={} cond={}: {}",
indType, condType, e.getMessage());
@@ -478,8 +575,12 @@ public class StrategyDslToTa4jAdapter {
}
}
private static int evalIndex(BarSeries trigger, BarSeries branch, int triggerIndex) {
private static int evalIndex(BarSeries trigger, BarSeries branch, int triggerIndex, boolean isBacktest) {
if (trigger == branch) return triggerIndex;
if (isBacktest) {
java.time.Instant t = trigger.getBar(triggerIndex).getEndTime();
return CrossSeriesRule.findLastIndexAtOrBefore(branch, t);
}
int end = branch.getEndIndex();
return end >= 0 ? end : 0;
}
@@ -491,25 +592,28 @@ public class StrategyDslToTa4jAdapter {
private final BarSeries triggerSeries;
private final BarSeries leftSeries;
private final BarSeries rightSeries;
private final boolean isBacktest;
CrossTimeframeCompositeRule(String condType,
Indicator<Num> left,
Indicator<Num> right,
BarSeries triggerSeries,
BarSeries leftSeries,
BarSeries rightSeries) {
BarSeries rightSeries,
boolean isBacktest) {
this.condType = condType;
this.left = left;
this.right = right;
this.triggerSeries = triggerSeries;
this.leftSeries = leftSeries;
this.rightSeries = rightSeries;
this.isBacktest = isBacktest;
}
@Override
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
int li = evalIndex(triggerSeries, leftSeries, index);
int ri = evalIndex(triggerSeries, rightSeries, index);
int li = evalIndex(triggerSeries, leftSeries, index, isBacktest);
int ri = evalIndex(triggerSeries, rightSeries, index, isBacktest);
if (li < 1 || ri < 1) return false;
Num l0 = left.getValue(li - 1);
@@ -566,8 +670,8 @@ public class StrategyDslToTa4jAdapter {
private Indicator<Num> resolveField(String field, String indType,
Map<String, Object> p, BarSeries s,
int condPeriod, int sidePeriod) {
return resolveField(field, indType, p, s, condPeriod, sidePeriod, null,
new RuleBuildContext(s, Map.of(), Map.of(), null, null));
return resolveField(field, indType, p, s, condPeriod, sidePeriod, null,
new RuleBuildContext(s, Map.of(), Map.of(), null, null, false, Map.of()));
}
private double resolveConstantField(String field) {