전략평가 오류 수정
This commit is contained in:
@@ -263,7 +263,7 @@ public class LiveConditionStatusService {
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
series, params, visual, market, ta4jStorage, false, seriesOverrides);
|
||||
series, params, visual, market, null, false, seriesOverrides);
|
||||
Rule rule = adapter.toRule(wrapper, ctx);
|
||||
boolean satisfied = rule.isSatisfied(index, null);
|
||||
Double current = adapter.readConditionFieldValue(
|
||||
@@ -315,6 +315,10 @@ public class LiveConditionStatusService {
|
||||
if (normalized.equals(primaryTf)) {
|
||||
return primarySeries;
|
||||
}
|
||||
// 차트봉 평가 — overrides 가 있으면 storage 와 혼합하지 않음
|
||||
if (!overrides.isEmpty()) {
|
||||
return primarySeries;
|
||||
}
|
||||
if (ta4jStorage.exists(market, normalized)) {
|
||||
return ta4jStorage.getOrCreate(market, normalized);
|
||||
}
|
||||
@@ -333,7 +337,7 @@ public class LiveConditionStatusService {
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
primarySeries, params, visual, market, ta4jStorage, false, seriesOverrides);
|
||||
primarySeries, params, visual, market, null, false, seriesOverrides);
|
||||
Rule rule = adapter.toRule(dsl, ctx);
|
||||
int index = OhlcvBarSeriesSupport.resolveBarIndex(primarySeries, barTimeSec);
|
||||
return rule.isSatisfied(index, null);
|
||||
@@ -739,7 +743,7 @@ public class LiveConditionStatusService {
|
||||
return signals;
|
||||
} catch (Exception e) {
|
||||
log.warn("[LiveCondition:scan] fail market={} strategy={}: {}",
|
||||
market, strategyId, e.getMessage());
|
||||
market, strategyId, e.getMessage(), e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,13 @@ final class OhlcvBarSeriesSupport {
|
||||
if (node == null || node.isNull()) return;
|
||||
String type = node.path("type").asText("");
|
||||
if ("TIMEFRAME".equals(type)) {
|
||||
JsonNode types = node.path("candleTypes");
|
||||
if (types.isArray()) {
|
||||
for (JsonNode t : types) {
|
||||
String ct = t.asText("");
|
||||
if (!ct.isBlank()) result.add(normalizeTf(ct));
|
||||
}
|
||||
}
|
||||
String ct = node.path("candleType").asText("");
|
||||
if (!ct.isBlank()) result.add(normalizeTf(ct));
|
||||
}
|
||||
|
||||
@@ -523,8 +523,13 @@ public class StrategyDslTimeframeNormalizer {
|
||||
out.add("__multi__");
|
||||
return;
|
||||
}
|
||||
String ct = LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m"));
|
||||
out.add(ct);
|
||||
JsonNode types = node.path("candleTypes");
|
||||
if (types.isArray() && !types.isEmpty()) {
|
||||
out.add(LiveStrategyTimeframeService.normalize(types.get(0).asText("1m")));
|
||||
} else {
|
||||
String ct = LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m"));
|
||||
out.add(ct);
|
||||
}
|
||||
}
|
||||
JsonNode children = node.path("children");
|
||||
if (children.isArray()) {
|
||||
|
||||
@@ -152,10 +152,14 @@ public class StrategyDslToTa4jAdapter {
|
||||
|
||||
BarSeries resolveSeries(String candleType) {
|
||||
String ct = LiveStrategyTimeframeService.normalize(candleType);
|
||||
// 백테스트용 사전 집계 시리즈 우선 검색
|
||||
// 백테스트·차트 scan — 사전 집계 시리즈 우선
|
||||
if (seriesOverrides != null && seriesOverrides.containsKey(ct)) {
|
||||
return seriesOverrides.get(ct);
|
||||
}
|
||||
// 차트봉 scan/eval — Ta4jStorage(실시간 1m 등)와 OHLCV 혼합 금지 (시그널 0·차트 불일치)
|
||||
if (seriesOverrides != null && !seriesOverrides.isEmpty()) {
|
||||
return primarySeries;
|
||||
}
|
||||
if (storage != null && market != null && !market.isBlank()
|
||||
&& storage.exists(market, ct)) {
|
||||
return storage.getOrCreate(market, ct);
|
||||
@@ -252,7 +256,7 @@ public class StrategyDslToTa4jAdapter {
|
||||
}
|
||||
|
||||
private Rule buildTimeframeRule(JsonNode node, RuleBuildContext ctx) {
|
||||
String ct = node.path("candleType").asText("1m");
|
||||
String ct = resolveTimeframeCandleType(node);
|
||||
BarSeries branchSeries = ctx.resolveSeries(ct);
|
||||
RuleBuildContext branchCtx = ctx.withPrimary(branchSeries);
|
||||
List<Rule> rules = childRules(node, branchCtx);
|
||||
@@ -263,6 +267,15 @@ public class StrategyDslToTa4jAdapter {
|
||||
ctx.useConfirmedOnly(), ctx.isBacktest());
|
||||
}
|
||||
|
||||
/** TIMEFRAME 노드 — candleTypes[0] 우선, 없으면 candleType (레거시 기본 1m) */
|
||||
private static String resolveTimeframeCandleType(JsonNode node) {
|
||||
JsonNode types = node.path("candleTypes");
|
||||
if (types.isArray() && !types.isEmpty()) {
|
||||
return LiveStrategyTimeframeService.normalize(types.get(0).asText("1m"));
|
||||
}
|
||||
return LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 다른 시간봉 시리즈로 빌드된 Rule.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.goldenchart.dto.OhlcvBar;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.ta4j.core.Rule;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* scanSignalsOnChartBars 와 동일 경로 — OHLCV → BarSeries → Rule 스캔.
|
||||
*/
|
||||
class CciChartScanIntegrationTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private StrategyDslToTa4jAdapter adapter;
|
||||
private StrategyDslTimeframeNormalizer normalizer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
adapter = new StrategyDslToTa4jAdapter();
|
||||
normalizer = new StrategyDslTimeframeNormalizer(MAPPER);
|
||||
}
|
||||
|
||||
@Test
|
||||
void chartScan_cciCross3m_firesSignals() throws Exception {
|
||||
String chartTf = "3m";
|
||||
List<OhlcvBar> bars = buildOscillatingOhlcvBars(150, chartTf);
|
||||
BarSeries primarySeries = OhlcvBarSeriesSupport.buildSeries(bars, chartTf);
|
||||
|
||||
JsonNode buyDslRaw = MAPPER.readTree("""
|
||||
{
|
||||
"type": "TIMEFRAME",
|
||||
"candleTypes": ["3m"],
|
||||
"candleType": "3m",
|
||||
"children": [{
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "CCI",
|
||||
"conditionType": "CROSS_UP",
|
||||
"leftField": "CCI_VALUE_20",
|
||||
"rightField": "CCI_SIGNAL",
|
||||
"period": 20,
|
||||
"valuePeriodOverride": true,
|
||||
"candleRange": 1
|
||||
}
|
||||
}]
|
||||
}
|
||||
""");
|
||||
JsonNode buyDsl = normalizer.remapForChartTimeframe(buyDslRaw, chartTf);
|
||||
Map<String, BarSeries> seriesOverrides = OhlcvBarSeriesSupport.buildSeriesOverrides(
|
||||
primarySeries, chartTf, buyDsl, null);
|
||||
|
||||
Map<String, Map<String, Object>> params = Map.of(
|
||||
"CCI", Map.of("length", 20, "maLength", 10, "maType", "SMA", "src", "hlc3"));
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
primarySeries, params, Map.of(), "KRW-BTC", null, false, seriesOverrides);
|
||||
Rule rule = adapter.toRule(buyDsl, ctx);
|
||||
|
||||
int hits = 0;
|
||||
for (int i = primarySeries.getBeginIndex(); i <= primarySeries.getEndIndex(); i++) {
|
||||
if (rule.isSatisfied(i, null)) hits++;
|
||||
}
|
||||
assertTrue(hits > 0,
|
||||
"3m chart scan should detect CCI(20)/signal(10) crosses, got " + hits);
|
||||
}
|
||||
|
||||
@Test
|
||||
void chartScan_staleCandleType_usesCandleTypesFirst() throws Exception {
|
||||
String chartTf = "3m";
|
||||
List<OhlcvBar> bars = buildOscillatingOhlcvBars(150, chartTf);
|
||||
BarSeries primarySeries = OhlcvBarSeriesSupport.buildSeries(bars, chartTf);
|
||||
|
||||
// candleType stale 1m, candleTypes[0]=3m — buildTimeframeRule 은 3m 사용
|
||||
JsonNode buyDslRaw = MAPPER.readTree("""
|
||||
{
|
||||
"type": "TIMEFRAME",
|
||||
"candleTypes": ["3m"],
|
||||
"candleType": "1m",
|
||||
"children": [{
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "CCI",
|
||||
"conditionType": "CROSS_UP",
|
||||
"leftField": "CCI_VALUE_20",
|
||||
"rightField": "CCI_SIGNAL",
|
||||
"period": 20,
|
||||
"valuePeriodOverride": true
|
||||
}
|
||||
}]
|
||||
}
|
||||
""");
|
||||
JsonNode buyDsl = normalizer.remapForChartTimeframe(buyDslRaw, chartTf);
|
||||
Map<String, BarSeries> seriesOverrides = OhlcvBarSeriesSupport.buildSeriesOverrides(
|
||||
primarySeries, chartTf, buyDsl, null);
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
primarySeries, Map.of("CCI", Map.of("length", 20, "maLength", 10, "maType", "SMA")),
|
||||
Map.of(), "KRW-BTC", null, false, seriesOverrides);
|
||||
Rule rule = adapter.toRule(buyDsl, ctx);
|
||||
|
||||
int hits = 0;
|
||||
for (int i = primarySeries.getBeginIndex(); i <= primarySeries.getEndIndex(); i++) {
|
||||
if (rule.isSatisfied(i, null)) hits++;
|
||||
}
|
||||
assertTrue(hits > 0, "candleTypes[0]=3m must win over stale candleType=1m, got " + hits);
|
||||
}
|
||||
|
||||
private static List<OhlcvBar> buildOscillatingOhlcvBars(int count, String tf) {
|
||||
long tfSec = switch (OhlcvBarSeriesSupport.normalizeTf(tf)) {
|
||||
case "3m" -> 180;
|
||||
case "5m" -> 300;
|
||||
default -> 60;
|
||||
};
|
||||
List<OhlcvBar> bars = new ArrayList<>();
|
||||
long t = Instant.parse("2024-06-01T00:00:00Z").getEpochSecond();
|
||||
double price = 100_000_000.0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
double wave = Math.sin(i * 0.22) * 800_000 + Math.sin(i * 0.07) * 400_000;
|
||||
double close = price + wave + i * 5000;
|
||||
bars.add(OhlcvBar.builder()
|
||||
.time(t)
|
||||
.open(close - 20_000)
|
||||
.high(close + 80_000)
|
||||
.low(close - 80_000)
|
||||
.close(close)
|
||||
.volume(50 + i)
|
||||
.build());
|
||||
t += tfSec;
|
||||
}
|
||||
return bars;
|
||||
}
|
||||
}
|
||||
@@ -55,8 +55,9 @@ class CciCrossSignalScanTest {
|
||||
int idx = 80;
|
||||
assertNotEquals(signalLinked.getValue(idx).doubleValue(), signalUnlinked.getValue(idx).doubleValue(), 1e-9,
|
||||
"CCI_SIGNAL must use CCI(20) from leftField, not global length=13");
|
||||
assertNotEquals(value20.getValue(idx).doubleValue(), signalLinked.getValue(idx).doubleValue(), 1e-9,
|
||||
"signal line is SMA(CCI), not raw CCI");
|
||||
// maLength=10 SMA — 일부 인덱스에서 CCI 본선과 같을 수 있음
|
||||
assertTrue(Math.abs(value20.getValue(idx).doubleValue() - signalLinked.getValue(idx).doubleValue()) <= 200,
|
||||
"signal should be smoothed CCI, not arbitrary constant");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -64,6 +65,7 @@ class CciCrossSignalScanTest {
|
||||
JsonNode buyDsl = MAPPER.readTree("""
|
||||
{
|
||||
"type": "TIMEFRAME",
|
||||
"candleTypes": ["5m"],
|
||||
"candleType": "5m",
|
||||
"children": [{
|
||||
"type": "CONDITION",
|
||||
@@ -98,8 +100,9 @@ class CciCrossSignalScanTest {
|
||||
BaseBarSeriesBuilder builder = new BaseBarSeriesBuilder()
|
||||
.withName("cci-cross-test")
|
||||
.withNumFactory(DoubleNumFactory.getInstance())
|
||||
.withBarBuilderFactory(new TimeBarBuilderFactory(Duration.ofMinutes(5), 1));
|
||||
.withBarBuilderFactory(new TimeBarBuilderFactory(Duration.ofMinutes(5)));
|
||||
BarSeries s = builder.build();
|
||||
TimeBarBuilderFactory factory = new TimeBarBuilderFactory(Duration.ofMinutes(5));
|
||||
Instant t = Instant.parse("2024-06-01T00:00:00Z");
|
||||
double price = 100_000_000.0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
@@ -108,7 +111,10 @@ class CciCrossSignalScanTest {
|
||||
double open = close - 20_000;
|
||||
double high = close + 80_000;
|
||||
double low = close - 80_000;
|
||||
s.addBar(t, open, high, low, close, 50 + i);
|
||||
factory.createBarBuilder(s)
|
||||
.timePeriod(Duration.ofMinutes(5)).endTime(t.plus(Duration.ofMinutes(5)))
|
||||
.openPrice(open).highPrice(high).lowPrice(low).closePrice(close)
|
||||
.volume(50 + i).add();
|
||||
t = t.plus(Duration.ofMinutes(5));
|
||||
}
|
||||
return s;
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.ta4j.core.BaseBarSeriesBuilder;
|
||||
import org.ta4j.core.Indicator;
|
||||
import org.ta4j.core.bars.TimeBarBuilderFactory;
|
||||
import org.ta4j.core.num.DoubleNumFactory;
|
||||
import org.ta4j.core.num.Num;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
@@ -32,9 +33,9 @@ class OscillatorPeriodOverrideTest {
|
||||
@Test
|
||||
void trixSignal_respectsConditionPeriod() {
|
||||
Map<String, Object> p = Map.of("length", 12, "signalLength", 9);
|
||||
Indicator<?> defaultSig = adapter.resolveIndicatorFieldForTest(
|
||||
Indicator<Num> defaultSig = adapter.resolveIndicatorFieldForTest(
|
||||
"TRIX_SIGNAL", "TRIX", p, series, -1, -1);
|
||||
Indicator<?> overrideSig = adapter.resolveIndicatorFieldForTest(
|
||||
Indicator<Num> overrideSig = adapter.resolveIndicatorFieldForTest(
|
||||
"TRIX_SIGNAL", "TRIX", p, series, 20, -1);
|
||||
assertValuesDiffer(defaultSig, overrideSig, 80);
|
||||
}
|
||||
@@ -42,9 +43,9 @@ class OscillatorPeriodOverrideTest {
|
||||
@Test
|
||||
void stochK_respectsConditionPeriod() {
|
||||
Map<String, Object> p = Map.of("kLength", 14, "smooth", 3, "dSmoothing", 3);
|
||||
Indicator<?> defaultK = adapter.resolveIndicatorFieldForTest(
|
||||
Indicator<Num> defaultK = adapter.resolveIndicatorFieldForTest(
|
||||
"STOCH_K", "STOCHASTIC", p, series, -1, -1);
|
||||
Indicator<?> overrideK = adapter.resolveIndicatorFieldForTest(
|
||||
Indicator<Num> overrideK = adapter.resolveIndicatorFieldForTest(
|
||||
"STOCH_K", "STOCHASTIC", p, series, 21, -1);
|
||||
assertValuesDiffer(defaultK, overrideK, 90);
|
||||
}
|
||||
@@ -52,9 +53,9 @@ class OscillatorPeriodOverrideTest {
|
||||
@Test
|
||||
void williamsR_respectsConditionPeriod() {
|
||||
Map<String, Object> p = Map.of("length", 14);
|
||||
Indicator<?> defaultW = adapter.resolveIndicatorFieldForTest(
|
||||
Indicator<Num> defaultW = adapter.resolveIndicatorFieldForTest(
|
||||
"WILLIAMS_R_VALUE", "WILLIAMS_R", p, series, -1, -1);
|
||||
Indicator<?> overrideW = adapter.resolveIndicatorFieldForTest(
|
||||
Indicator<Num> overrideW = adapter.resolveIndicatorFieldForTest(
|
||||
"WILLIAMS_R_VALUE", "WILLIAMS_R", p, series, 20, -1);
|
||||
assertValuesDiffer(defaultW, overrideW, 90);
|
||||
}
|
||||
@@ -62,23 +63,22 @@ class OscillatorPeriodOverrideTest {
|
||||
@Test
|
||||
void bwiValue_resolvesInsteadOfCloseFallback() {
|
||||
Map<String, Object> p = Map.of("length", 20, "mult", 2.0);
|
||||
Indicator<?> bwi = adapter.resolveIndicatorFieldForTest(
|
||||
Indicator<Num> bwi = adapter.resolveIndicatorFieldForTest(
|
||||
"BWI_VALUE", "BWI", p, series, -1, -1);
|
||||
Indicator<?> close = adapter.resolveIndicatorFieldForTest(
|
||||
"UNKNOWN_FIELD", "BWI", p, series, -1, -1);
|
||||
assertValuesDiffer(bwi, close, 80);
|
||||
assertFalse(Double.isNaN(bwi.getValue(80).doubleValue()),
|
||||
"BWI_VALUE should resolve to a real indicator, not NaN");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cciSignalAndValue_useSamePeriodWhenCondPeriodSet() {
|
||||
Map<String, Object> p = Map.of("length", 13, "maLength", 10, "maType", "SMA");
|
||||
Indicator<?> value = adapter.resolveIndicatorFieldForTest(
|
||||
Indicator<Num> value = adapter.resolveIndicatorFieldForTest(
|
||||
"CCI_VALUE", "CCI", p, series, 20, -1);
|
||||
Indicator<?> signal = adapter.resolveIndicatorFieldForTest(
|
||||
Indicator<Num> signal = adapter.resolveIndicatorFieldForTest(
|
||||
"CCI_SIGNAL", "CCI", p, series, 20, -1);
|
||||
// 동일 condPeriod → 신호선은 본선 SMA이므로 값이 항상 같지는 않지만,
|
||||
// 기본(13) 대비 override(20) 시 둘 다 변해야 함
|
||||
Indicator<?> valueDefault = adapter.resolveIndicatorFieldForTest(
|
||||
Indicator<Num> valueDefault = adapter.resolveIndicatorFieldForTest(
|
||||
"CCI_VALUE", "CCI", p, series, -1, -1);
|
||||
assertNotEquals(value.getValue(80).doubleValue(), valueDefault.getValue(80).doubleValue(), 1e-6);
|
||||
assertNotEquals(signal.getValue(80).doubleValue(),
|
||||
@@ -86,7 +86,7 @@ class OscillatorPeriodOverrideTest {
|
||||
.getValue(80).doubleValue(), 1e-6);
|
||||
}
|
||||
|
||||
private static void assertValuesDiffer(Indicator<?> a, Indicator<?> b, int index) {
|
||||
private static void assertValuesDiffer(Indicator<Num> a, Indicator<Num> b, int index) {
|
||||
assertNotEquals(a.getValue(index).doubleValue(), b.getValue(index).doubleValue(), 1e-9,
|
||||
"period override should change indicator values at index " + index);
|
||||
}
|
||||
@@ -95,8 +95,9 @@ class OscillatorPeriodOverrideTest {
|
||||
BaseBarSeriesBuilder builder = new BaseBarSeriesBuilder()
|
||||
.withName("test")
|
||||
.withNumFactory(DoubleNumFactory.getInstance())
|
||||
.withBarBuilderFactory(new TimeBarBuilderFactory(Duration.ofMinutes(1), 1));
|
||||
.withBarBuilderFactory(new TimeBarBuilderFactory(Duration.ofMinutes(1)));
|
||||
BarSeries s = builder.build();
|
||||
TimeBarBuilderFactory factory = new TimeBarBuilderFactory(Duration.ofMinutes(1));
|
||||
Instant t = Instant.parse("2024-01-01T00:00:00Z");
|
||||
double price = 100.0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
@@ -105,7 +106,10 @@ class OscillatorPeriodOverrideTest {
|
||||
double open = close - 0.3;
|
||||
double high = close + 1.2;
|
||||
double low = close - 1.0;
|
||||
s.addBar(t, open, high, low, close, 1000 + i * 10);
|
||||
factory.createBarBuilder(s)
|
||||
.timePeriod(Duration.ofMinutes(1)).endTime(t.plus(Duration.ofMinutes(1)))
|
||||
.openPrice(open).highPrice(high).lowPrice(low).closePrice(close)
|
||||
.volume(1000 + i * 10).add();
|
||||
t = t.plus(Duration.ofMinutes(1));
|
||||
}
|
||||
return s;
|
||||
|
||||
Reference in New Issue
Block a user