From ac55748aec113e3f07720188fdc6f6865f2e525f Mon Sep 17 00:00:00 2001 From: Macbook Date: Thu, 18 Jun 2026 00:56:50 +0900 Subject: [PATCH] =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/LiveConditionStatusService.java | 40 ++++- .../service/RsiChartScanIntegrationTest.java | 166 ++++++++++++++++++ frontend/scripts/test-threshold-normalize.mjs | 18 ++ .../strategyEditor/ComboFieldSelect.tsx | 18 +- .../StrategyEvaluationChart.tsx | 2 +- .../src/utils/strategyConditionNormalize.ts | 14 +- frontend/src/utils/strategyEditorShared.tsx | 3 + 7 files changed, 254 insertions(+), 7 deletions(-) create mode 100644 backend/src/test/java/com/goldenchart/service/RsiChartScanIntegrationTest.java diff --git a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java index aacdc2b..6fe2913 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java @@ -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); diff --git a/backend/src/test/java/com/goldenchart/service/RsiChartScanIntegrationTest.java b/backend/src/test/java/com/goldenchart/service/RsiChartScanIntegrationTest.java new file mode 100644 index 0000000..f6222c8 --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/RsiChartScanIntegrationTest.java @@ -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 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 seriesOverrides = OhlcvBarSeriesSupport.buildSeriesOverrides( + primarySeries, chartTf, buyDsl, null); + + Map> 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 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 seriesOverrides = OhlcvBarSeriesSupport.buildSeriesOverrides( + primarySeries, chartTf, buyDsl, null); + Map> 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 buildOscillatingOhlcvBars(int count, String tf) { + long periodSec = switch (tf) { + case "3m" -> 180; + case "5m" -> 300; + default -> 60; + }; + List 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; + } +} diff --git a/frontend/scripts/test-threshold-normalize.mjs b/frontend/scripts/test-threshold-normalize.mjs index 4c62b9d..1ab1d62 100644 --- a/frontend/scripts/test-threshold-normalize.mjs +++ b/frontend/scripts/test-threshold-normalize.mjs @@ -65,4 +65,22 @@ assertOk('directInput 55 => K_55', direct55.rightField === 'K_55' && direct55.ta const filled = ensureDirectThresholdSnapshot({ indicatorType: 'RSI', rightField: 'K_55', thresholdOverride: false }); assertOk('K_55 fills targetValue', filled.targetValue === 55 && filled.thresholdOverride === true); +// 레거시 마이그레이션 — K_55 를 HL_MID(50) 로 접지 않음 +function migrateK(cond) { + if (cond.thresholdOverride === true) return cond; + const field = cond.rightField; + if (!field?.startsWith('K_')) return cond; + const val = parseFloat(field.slice(2)); + if (!Number.isFinite(val)) return cond; + const DEFAULT = { RSI: { over: 70, mid: 50, under: 30 } }; + const th = DEFAULT.RSI; + const eps = 0.0001; + if (th.over != null && Math.abs(th.over - val) < eps) return { ...cond, rightField: 'HL_OVER', thresholdOverride: false }; + if (th.mid != null && Math.abs(th.mid - val) < eps) return { ...cond, rightField: 'HL_MID', thresholdOverride: false }; + if (th.under != null && Math.abs(th.under - val) < eps) return { ...cond, rightField: 'HL_UNDER', thresholdOverride: false }; + return { ...cond, rightField: field, targetValue: val, thresholdOverride: true }; +} +const migrated = migrateK({ indicatorType: 'RSI', rightField: 'K_55', thresholdOverride: false }); +assertOk('migrate K_55 stays K_55', migrated.rightField === 'K_55' && migrated.targetValue === 55); + process.exit(failed > 0 ? 1 : 0); diff --git a/frontend/src/components/strategyEditor/ComboFieldSelect.tsx b/frontend/src/components/strategyEditor/ComboFieldSelect.tsx index d980ea4..73a9e26 100644 --- a/frontend/src/components/strategyEditor/ComboFieldSelect.tsx +++ b/frontend/src/components/strategyEditor/ComboFieldSelect.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; export const COMBO_FIELD_CUSTOM = '__field_custom__'; @@ -117,6 +117,22 @@ export default function ComboFieldSelect({ setDraft(String(clamped)); }, [draft, allowDecimal, min, max, onCustomNumberChange, customNumber]); + const commitRef = useRef(commitCustom); + commitRef.current = commitCustom; + + // 직접입력 blur 전 탭 전환·저장 시 값 유실 방지 + useEffect(() => () => { commitRef.current(); }, []); + + useEffect(() => { + if (!focused || mode !== 'custom') return; + const parsed = parseDraft(draft, allowDecimal); + if (parsed == null) return; + const clamped = clampValue(parsed, min, max, allowDecimal); + if (clamped === customNumber) return; + const t = window.setTimeout(() => onCustomNumberChange(clamped), 400); + return () => window.clearTimeout(t); + }, [draft, focused, mode, allowDecimal, min, max, customNumber, onCustomNumberChange]); + return (