From 53747b877328bef3913ec72d546ccb09eb6ae521 Mon Sep 17 00:00:00 2001 From: Macbook Date: Mon, 22 Jun 2026 12:49:36 +0900 Subject: [PATCH] =?UTF-8?q?9=EC=9D=BC=20=EC=8B=A0=EA=B3=A0=EA=B0=80=20?= =?UTF-8?q?=EB=A7=A4=EC=88=98=20=EC=A1=B0=EA=B1=B4=20=EC=98=A4=EB=A5=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/IndicatorHlineResolver.java | 10 ++++ .../service/IndicatorHlineResolverTest.java | 10 ++++ .../service/PriceExtremeRuleTest.java | 48 +++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/backend/src/main/java/com/goldenchart/service/IndicatorHlineResolver.java b/backend/src/main/java/com/goldenchart/service/IndicatorHlineResolver.java index dc1ea7c..5faafcf 100644 --- a/backend/src/main/java/com/goldenchart/service/IndicatorHlineResolver.java +++ b/backend/src/main/java/com/goldenchart/service/IndicatorHlineResolver.java @@ -20,6 +20,12 @@ public final class IndicatorHlineResolver { if (field == null || field.isBlank()) return null; + // 지표 시리즈 필드 — 접미 숫자를 임계값 상수로 오인하지 않음 (NH_PRIOR_9 → 9.0 버그 방지) + if (field.startsWith("NH_PRIOR_") || field.startsWith("NL_PRIOR_") + || field.startsWith("DC_UPPER_") || field.startsWith("DC_LOWER_") || field.startsWith("DC_MIDDLE_")) { + return null; + } + boolean override = cond != null && cond.path("thresholdOverride").asBoolean(false); // K_* = 직접입력 숫자 스냅샷 — hline 역할로 치환하지 않음 (K_55 → 55) @@ -54,6 +60,10 @@ public final class IndicatorHlineResolver { private static boolean isThresholdRoleField(String field) { if (field == null || field.isBlank()) return false; if (field.startsWith("K_")) return true; + if (field.startsWith("NH_PRIOR_") || field.startsWith("NL_PRIOR_") + || field.startsWith("DC_UPPER_") || field.startsWith("DC_LOWER_") || field.startsWith("DC_MIDDLE_")) { + return false; + } if ("HL_OVER".equals(field) || "HL_MID".equals(field) || "HL_UNDER".equals(field)) { return true; } diff --git a/backend/src/test/java/com/goldenchart/service/IndicatorHlineResolverTest.java b/backend/src/test/java/com/goldenchart/service/IndicatorHlineResolverTest.java index 1f57b63..912b0ca 100644 --- a/backend/src/test/java/com/goldenchart/service/IndicatorHlineResolverTest.java +++ b/backend/src/test/java/com/goldenchart/service/IndicatorHlineResolverTest.java @@ -68,4 +68,14 @@ class IndicatorHlineResolverTest { assertNull(IndicatorHlineResolver.resolveThresholdField( null, "", "RSI", Map.of())); } + + @Test + void nhPriorField_notParsedAsNumericConstant() { + assertNull(IndicatorHlineResolver.resolveThresholdField( + null, "NH_PRIOR_9", "NEW_HIGH", Map.of())); + assertNull(IndicatorHlineResolver.resolveThresholdField( + null, "NL_PRIOR_20", "NEW_LOW", Map.of())); + assertNull(IndicatorHlineResolver.resolveThresholdField( + null, "DC_UPPER_9", "DONCHIAN", Map.of())); + } } diff --git a/backend/src/test/java/com/goldenchart/service/PriceExtremeRuleTest.java b/backend/src/test/java/com/goldenchart/service/PriceExtremeRuleTest.java index 53da55f..fccf993 100644 --- a/backend/src/test/java/com/goldenchart/service/PriceExtremeRuleTest.java +++ b/backend/src/test/java/com/goldenchart/service/PriceExtremeRuleTest.java @@ -173,8 +173,56 @@ class PriceExtremeRuleTest { assertFalse(hits.isEmpty(), "하락 추세에서 9·20일 신저가 복합 매도 조건이 최소 1회 충족되어야 함"); } + @Test + void nhPrior_crossUp_firesOnRealPriceBreakout_notConstantNine() throws Exception { + BarSeries series = buildFlatThenBreakoutSeries(97_000_000.0, 97_500_000.0, 15); + Rule buy = rule(series, """ + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_HIGH", + "conditionType": "CROSS_UP", + "leftField": "CLOSE_PRICE", + "rightField": "NH_PRIOR_9", + "period": 9 + }} + """); + + List hits = satisfiedIndices(buy, series); + assertFalse(hits.isEmpty(), + "97M대 종가가 9일 신고가선 상향 돌파 시 CROSS_UP — NH_PRIOR_9를 상수 9로 해석하면 영원히 false"); + assertTrue(hits.stream().anyMatch(i -> i >= 9), + "9봉 워밍업 이후 돌파 봉에서만 시그널"); + } + // ── helpers ───────────────────────────────────────────────────────────── + /** 횡보 후 한 봉에 종가 돌파 — NH_PRIOR CROSS_UP 검증용 */ + private static BarSeries buildFlatThenBreakoutSeries(double flatPrice, double breakoutClose, int flatBars) { + Duration period = Duration.ofMinutes(3); + BarSeries series = new BaseBarSeriesBuilder() + .withName("flat-breakout") + .withNumFactory(DoubleNumFactory.getInstance()) + .withBarBuilderFactory(new TimeBarBuilderFactory()) + .build(); + TimeBarBuilderFactory factory = new TimeBarBuilderFactory(); + Instant base = Instant.parse("2024-01-01T00:00:00Z"); + for (int i = 0; i < flatBars; i++) { + factory.createBarBuilder(series) + .timePeriod(period) + .endTime(base.plus(period.multipliedBy(i + 1L))) + .openPrice(flatPrice).highPrice(flatPrice + 1000).lowPrice(flatPrice - 1000).closePrice(flatPrice) + .volume(1000) + .add(); + } + int i = flatBars; + factory.createBarBuilder(series) + .timePeriod(period) + .endTime(base.plus(period.multipliedBy(i + 1L))) + .openPrice(flatPrice).highPrice(breakoutClose + 500).lowPrice(flatPrice - 500).closePrice(breakoutClose) + .volume(5000) + .add(); + return series; + } + private Rule rule(BarSeries series, String json) throws Exception { JsonNode node = MAPPER.readTree(json); return adapter.toRule(node, series, Map.of());