This commit is contained in:
Macbook
2026-06-18 00:56:50 +09:00
parent e5b5af093e
commit ac55748aec
7 changed files with 254 additions and 7 deletions
@@ -203,6 +203,42 @@ public class LiveConditionStatusService {
return bestIdx;
}
/** scan 0건 시 DSL 임계값 요약 로그 */
private static String summarizeBuyCondition(JsonNode buyDsl) {
if (buyDsl == null || buyDsl.isNull()) return "null";
JsonNode cond = findFirstCondition(buyDsl);
if (cond == null) return "no-condition";
String rf = cond.path("rightField").asText("");
boolean override = cond.path("thresholdOverride").asBoolean(false);
double target = cond.path("targetValue").asDouble(Double.NaN);
Double resolved = IndicatorHlineResolver.resolveThresholdField(cond, rf,
cond.path("indicatorType").asText(""), Map.of());
return String.format("right=%s override=%s target=%s resolved=%s type=%s left=%s",
rf, override, Double.isNaN(target) ? "-" : target,
resolved != null ? resolved : "-",
cond.path("conditionType").asText(""),
cond.path("leftField").asText(""));
}
private static JsonNode findFirstCondition(JsonNode node) {
if (node == null || node.isNull()) return null;
if (node.has("condition")) {
JsonNode c = node.get("condition");
if (c != null && c.has("indicatorType")) return c;
}
if (node.has("indicatorType")) return node;
JsonNode children = node.get("children");
if (children != null && children.isArray()) {
for (JsonNode ch : children) {
JsonNode found = findFirstCondition(ch);
if (found != null) return found;
}
}
JsonNode child = node.get("child");
if (child != null) return findFirstCondition(child);
return null;
}
/** Ta4j Bar → 차트용 봉 시작 시각(초) — BacktestingService.barStartEpoch 와 동일 */
private static long barOpenEpochSec(BarSeries series, int index) {
var bar = series.getBar(index);
@@ -781,9 +817,9 @@ public class LiveConditionStatusService {
}
}
if (signals.isEmpty()) {
log.info("[LiveCondition:scan] 0 signals market={} strategy={} tf={} bars={} eval={}..{}",
log.info("[LiveCondition:scan] 0 signals market={} strategy={} tf={} bars={} eval={}..{} cond={}",
market, strategyId, primaryTf, primarySeries.getBarCount(), startIdx,
primarySeries.getEndIndex());
primarySeries.getEndIndex(), summarizeBuyCondition(buyDsl));
} else {
log.debug("[LiveCondition:scan] market={} strategy={} tf={} buy={} sell={}",
market, strategyId, primaryTf, buyHits, sellHits);
@@ -0,0 +1,166 @@
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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* scanSignalsOnChartBars 와 동일 경로 — RSI K_55 직접입력 임계값 평가.
*/
class RsiChartScanIntegrationTest {
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_rsiCrossUpK55_firesSignals() throws Exception {
String chartTf = "3m";
List<OhlcvBar> bars = buildOscillatingOhlcvBars(200, chartTf);
BarSeries primarySeries = OhlcvBarSeriesSupport.buildSeries(bars, chartTf);
JsonNode buyDslRaw = MAPPER.readTree("""
{
"type": "TIMEFRAME",
"candleTypes": ["3m"],
"candleType": "3m",
"children": [{
"type": "CONDITION",
"condition": {
"indicatorType": "RSI",
"conditionType": "CROSS_UP",
"leftField": "RSI_VALUE_9",
"rightField": "K_55",
"period": 9,
"valuePeriodOverride": true,
"thresholdOverride": true,
"targetValue": 55,
"candleRange": 1
}
}]
}
""");
JsonNode normalized = StrategyConditionThresholdNormalizer.normalizeTree(buyDslRaw);
JsonNode buyDsl = normalizer.remapForChartTimeframe(normalized, chartTf);
Map<String, BarSeries> seriesOverrides = OhlcvBarSeriesSupport.buildSeriesOverrides(
primarySeries, chartTf, buyDsl, null);
Map<String, Map<String, Object>> params = Map.of(
"RSI", Map.of("length", 9, "maLength", 5, "maType", "SMA", "src", "close"));
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
new StrategyDslToTa4jAdapter.RuleBuildContext(
primarySeries, params, Map.of(), "KRW-BTC", null, false, seriesOverrides);
Rule rule = adapter.toRule(buyDsl, ctx);
int hits55 = countHits(rule, primarySeries);
assertFalse(hits55 == 0, "K_55 chart scan should produce CROSS_UP signals, got " + hits55);
JsonNode buyDsl50 = buyDsl.deepCopy();
JsonNode cond = buyDsl50.path("children").get(0).path("condition");
((com.fasterxml.jackson.databind.node.ObjectNode) cond).put("rightField", "K_50");
((com.fasterxml.jackson.databind.node.ObjectNode) cond).put("targetValue", 50);
Rule rule50 = adapter.toRule(buyDsl50, ctx);
int hits50 = countHits(rule50, primarySeries);
assertTrue(hits55 <= hits50,
"K_55 should have <= signals than K_50, 55=" + hits55 + " 50=" + hits50);
}
@Test
void chartScan_k55LegacyWithoutOverride_usesFieldNumberNotHlMid() throws Exception {
String chartTf = "3m";
List<OhlcvBar> bars = buildOscillatingOhlcvBars(200, chartTf);
BarSeries primarySeries = OhlcvBarSeriesSupport.buildSeries(bars, chartTf);
JsonNode buyDslRaw = MAPPER.readTree("""
{
"type": "TIMEFRAME",
"candleType": "3m",
"children": [{
"type": "CONDITION",
"condition": {
"indicatorType": "RSI",
"conditionType": "CROSS_UP",
"leftField": "RSI_VALUE_9",
"rightField": "K_55",
"period": 9,
"valuePeriodOverride": true,
"thresholdOverride": false
}
}]
}
""");
JsonNode buyDsl = normalizer.remapForChartTimeframe(
StrategyConditionThresholdNormalizer.normalizeTree(buyDslRaw), chartTf);
Map<String, BarSeries> seriesOverrides = OhlcvBarSeriesSupport.buildSeriesOverrides(
primarySeries, chartTf, buyDsl, null);
Map<String, Map<String, Object>> params = Map.of("RSI", Map.of("length", 9));
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
new StrategyDslToTa4jAdapter.RuleBuildContext(
primarySeries, params, Map.of(), null, null, false, seriesOverrides);
JsonNode cond = buyDsl.path("children").get(0).path("condition");
Double resolved = IndicatorHlineResolver.resolveThresholdField(
cond, "K_55", "RSI", Map.of(
"RSI", Map.of("hlines", List.of(
Map.of("label", "중앙선", "price", 50)))));
org.junit.jupiter.api.Assertions.assertEquals(55.0, resolved, 0.0001);
Rule rule = adapter.toRule(buyDsl, ctx);
assertFalse(countHits(rule, primarySeries) == 0, "legacy K_55 should evaluate at 55");
}
private static int countHits(Rule rule, BarSeries series) {
int hits = 0;
for (int i = series.getBeginIndex(); i <= series.getEndIndex(); i++) {
if (rule.isSatisfied(i, null)) hits++;
}
return hits;
}
private static List<OhlcvBar> buildOscillatingOhlcvBars(int count, String tf) {
long periodSec = switch (tf) {
case "3m" -> 180;
case "5m" -> 300;
default -> 60;
};
List<OhlcvBar> bars = new ArrayList<>();
long t = Instant.parse("2024-01-01T00:00:00Z").getEpochSecond();
double price = 100;
for (int i = 0; i < count; i++) {
double swing = Math.sin(i * 0.15) * 8 + Math.sin(i * 0.04) * 4;
price = 100 + swing + (i * 0.02);
bars.add(OhlcvBar.builder()
.time(t)
.open(price - 0.5)
.high(price + 1.5)
.low(price - 1.5)
.close(price)
.volume(1000)
.build());
t += periodSec;
}
return bars;
}
}