알림팝업 수정, 전략평가 로직 개선
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user