직접입력값 평가오류 수정

This commit is contained in:
Macbook
2026-06-18 00:47:58 +09:00
parent bd6094f71f
commit e5b5af093e
11 changed files with 297 additions and 284 deletions
@@ -22,13 +22,15 @@ public final class IndicatorHlineResolver {
boolean override = cond != null && cond.path("thresholdOverride").asBoolean(false);
// K_ 직접입력 — targetValue·override 시 필드명/스냅샷 숫자 우선
// K_* = 직접입력 숫자 스냅샷 — hline 역할로 치환하지 않음 (K_55 → 55)
if (field.startsWith("K_")) {
if (cond != null && cond.has("targetValue") && !cond.path("targetValue").isNull()) {
return cond.path("targetValue").asDouble();
}
if (override) {
try { return Double.parseDouble(field.substring(2)); } catch (NumberFormatException ignored) {}
try {
return Double.parseDouble(field.substring(2));
} catch (NumberFormatException ignored) {
return null;
}
}
@@ -36,9 +38,6 @@ public final class IndicatorHlineResolver {
if (cond.has("targetValue") && !cond.path("targetValue").isNull()) {
return cond.path("targetValue").asDouble();
}
if (field.startsWith("K_")) {
try { return Double.parseDouble(field.substring(2)); } catch (NumberFormatException ignored) {}
}
return legacyNumericField(field);
}
@@ -47,12 +46,6 @@ public final class IndicatorHlineResolver {
if (fromVisual != null) return fromVisual;
}
if (field.startsWith("K_")) {
Double fromVisual = inferRoleFromLegacyK(field, dslIndicatorType, indicatorVisual);
if (fromVisual != null) return fromVisual;
try { return Double.parseDouble(field.substring(2)); } catch (NumberFormatException ignored) {}
}
return legacyNumericField(field);
}
@@ -99,22 +92,6 @@ public final class IndicatorHlineResolver {
return defaultForRole(dslIndicatorType, role);
}
private static Double inferRoleFromLegacyK(
String kField,
String dslIndicatorType,
Map<String, Map<String, Object>> indicatorVisual) {
try {
double val = Double.parseDouble(kField.substring(2));
for (String role : List.of("HL_OVER", "HL_MID", "HL_UNDER")) {
Double roleVal = fromHlineRole(role, dslIndicatorType, indicatorVisual);
if (roleVal != null && Math.abs(roleVal - val) < 0.0001) {
return roleVal;
}
}
} catch (NumberFormatException ignored) {}
return null;
}
private static Double legacyNumericField(String field) {
if (field.contains("_NEG")) {
String[] parts = field.split("_NEG");
@@ -3,12 +3,11 @@ package com.goldenchart.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.Map;
import java.util.Locale;
/**
* 전략 조건 DSL — K_50 등 직접입력 스냅샷을 hline 역할(HL_MID 등)로 정규화.
* 프론트 normalizeConditionForPersistence 와 동일 의도.
* 전략 조건 DSL — 직접입력 K_* 필드에 targetValue·thresholdOverride 동기화.
* HL_* 프리셋은 그대로 두며, K_55 등 입력값을 중앙선 역할로 치환하지 않는다.
*/
public final class StrategyConditionThresholdNormalizer {
@@ -44,82 +43,23 @@ public final class StrategyConditionThresholdNormalizer {
if (!indType.isBlank()) {
cond.put("indicatorType", indType);
}
normalizeThresholdField(cond, "rightField", indType);
normalizeThresholdField(cond, "leftField", indType);
syncDirectKField(cond, "rightField");
syncDirectKField(cond, "leftField");
}
private static void normalizeThresholdField(ObjectNode cond, String fieldKey, String indType) {
/** K_55 → targetValue 55, thresholdOverride true (평가 시 입력 숫자 그대로) */
private static void syncDirectKField(ObjectNode cond, String fieldKey) {
String field = cond.path(fieldKey).asText("");
if (field.isBlank()) return;
boolean override = cond.path("thresholdOverride").asBoolean(false);
Double value = readThresholdValue(cond, field);
if (override || field.startsWith("K_")) {
String role = inferRoleForValue(indType, value, field);
if (role != null) {
cond.put(fieldKey, role);
cond.remove("targetValue");
cond.put("thresholdOverride", false);
return;
if (!field.startsWith("K_")) return;
try {
double val = Double.parseDouble(field.substring(2));
if (!cond.has("targetValue") || cond.path("targetValue").isNull()) {
cond.put("targetValue", val);
}
cond.put("thresholdOverride", true);
} catch (NumberFormatException ignored) {
// 비정상 K_ 필드는 그대로 둠
}
if (!override && field.startsWith("K_")) {
String role = inferRoleForValue(indType, value, field);
if (role != null) {
cond.put(fieldKey, role);
cond.remove("targetValue");
cond.put("thresholdOverride", false);
}
}
}
private static Double readThresholdValue(ObjectNode cond, String field) {
if (cond.has("targetValue") && !cond.path("targetValue").isNull()) {
return cond.path("targetValue").asDouble();
}
if (field.startsWith("K_")) {
try {
return Double.parseDouble(field.substring(2));
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
private static String inferRoleForValue(String indicatorType, Double value, String field) {
if (value == null && field.startsWith("K_")) {
try {
value = Double.parseDouble(field.substring(2));
} catch (NumberFormatException ignored) {
return null;
}
}
if (value == null || !Double.isFinite(value)) return null;
double eps = 0.0001;
for (Map.Entry<String, Double> e : defaultRoles(indicatorType).entrySet()) {
if (Math.abs(e.getValue() - value) < eps) {
return e.getKey();
}
}
return null;
}
/** HL_OVER / HL_MID / HL_UNDER → 기본 숫자 (IndicatorHlineResolver.defaultForRole 과 동일) */
private static Map<String, Double> defaultRoles(String indicatorType) {
return switch (indicatorType) {
case "RSI" -> Map.of("HL_OVER", 70.0, "HL_MID", 50.0, "HL_UNDER", 30.0);
case "STOCHASTIC" -> Map.of("HL_OVER", 80.0, "HL_MID", 50.0, "HL_UNDER", 20.0);
case "CCI" -> Map.of("HL_OVER", 100.0, "HL_MID", 0.0, "HL_UNDER", -100.0);
case "WILLIAMS_R" -> Map.of("HL_OVER", -20.0, "HL_MID", -50.0, "HL_UNDER", -80.0);
case "ADX" -> Map.of("HL_MID", 25.0);
case "DISPARITY" -> Map.of("HL_MID", 100.0);
case "VOLUME_OSC", "TRIX", "MACD" -> Map.of("HL_MID", 0.0);
default -> Map.of();
};
}
/** JSON 문자열 보정 — 파싱 실패 시 원본 반환 */
@@ -0,0 +1,60 @@
package com.goldenchart.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class IndicatorHlineResolverTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Test
void k55_alwaysResolvesTo55_notNearestHline() throws Exception {
var cond = MAPPER.readTree("""
{"thresholdOverride": false, "targetValue": null}
""");
Map<String, Map<String, Object>> visual = Map.of(
"RSI", Map.of("hlines", java.util.List.of(
Map.of("label", "과열선", "price", 70),
Map.of("label", "중앙선", "price", 50),
Map.of("label", "침체선", "price", 30))));
Double val = IndicatorHlineResolver.resolveThresholdField(
cond, "K_55", "RSI", visual);
assertEquals(55.0, val, 0.0001);
}
@Test
void k55_withTargetValue_prefersTargetValue() throws Exception {
var cond = MAPPER.readTree("""
{"thresholdOverride": true, "targetValue": 55}
""");
Double val = IndicatorHlineResolver.resolveThresholdField(
cond, "K_55", "RSI", Map.of());
assertEquals(55.0, val, 0.0001);
}
@Test
void hlMid_usesVisualNotKSemantics() throws Exception {
var cond = MAPPER.readTree("""
{"thresholdOverride": false}
""");
Map<String, Map<String, Object>> visual = Map.of(
"RSI", Map.of("hlines", java.util.List.of(
Map.of("label", "중앙선", "price", 48))));
Double val = IndicatorHlineResolver.resolveThresholdField(
cond, "HL_MID", "RSI", visual);
assertEquals(48.0, val, 0.0001);
}
@Test
void blankField_returnsNull() {
assertNull(IndicatorHlineResolver.resolveThresholdField(
null, "", "RSI", Map.of()));
}
}
@@ -62,6 +62,71 @@ class RsiThresholdCrossSignalTest {
""");
}
@Test
void rsiCrossUp_k55_producesSignals() throws Exception {
List<Integer> hits50 = scanHits("""
{
"indicatorType": "RSI",
"conditionType": "CROSS_UP",
"leftField": "RSI_VALUE_9",
"rightField": "K_50",
"period": 9,
"valuePeriodOverride": true,
"thresholdOverride": true,
"targetValue": 50,
"candleRange": 1
}
""", Map.of("RSI", Map.of("length", 9)), Map.of());
List<Integer> hits55 = scanHits("""
{
"indicatorType": "RSI",
"conditionType": "CROSS_UP",
"leftField": "RSI_VALUE_9",
"rightField": "K_55",
"period": 9,
"valuePeriodOverride": true,
"thresholdOverride": true,
"targetValue": 55,
"candleRange": 1
}
""", Map.of("RSI", Map.of("length", 9)), Map.of());
org.junit.jupiter.api.Assertions.assertFalse(hits55.isEmpty(),
"K_55 should produce CROSS_UP signals on oscillating series");
org.junit.jupiter.api.Assertions.assertTrue(hits55.size() <= hits50.size(),
"K_55 (higher threshold) should have <= hits than K_50");
}
@Test
void rsiCrossUp_k55_withoutTargetValue_usesFieldNumber() throws Exception {
List<Integer> hitsWithTarget = scanHits("""
{
"indicatorType": "RSI",
"conditionType": "CROSS_UP",
"leftField": "RSI_VALUE_9",
"rightField": "K_55",
"period": 9,
"valuePeriodOverride": true,
"thresholdOverride": true,
"targetValue": 55,
"candleRange": 1
}
""", Map.of("RSI", Map.of("length", 9)), Map.of());
List<Integer> hitsFieldOnly = scanHits("""
{
"indicatorType": "RSI",
"conditionType": "CROSS_UP",
"leftField": "RSI_VALUE_9",
"rightField": "K_55",
"period": 9,
"valuePeriodOverride": true,
"thresholdOverride": false,
"candleRange": 1
}
""", Map.of("RSI", Map.of("length", 9)), Map.of());
assertEquals(hitsWithTarget, hitsFieldOnly,
"K_55 field should resolve to 55 even without targetValue");
}
@Test
void rsiCrossUp_hlMid_equalsK50WithoutOverride() throws Exception {
assertSignalHitsEqual(
@@ -5,14 +5,14 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class StrategyConditionThresholdNormalizerTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Test
void normalizesK50OverrideToHlMid() throws Exception {
void preservesK50DirectInput() throws Exception {
JsonNode root = MAPPER.readTree("""
{
"type": "CONDITION",
@@ -29,11 +29,53 @@ class StrategyConditionThresholdNormalizerTest {
}
""");
JsonNode normalized = StrategyConditionThresholdNormalizer.normalizeTree(root);
JsonNode cond = normalized.path("condition");
JsonNode cond = StrategyConditionThresholdNormalizer.normalizeTree(root).path("condition");
assertEquals("HL_MID", cond.path("rightField").asText());
assertFalse(cond.path("thresholdOverride").asBoolean(true));
assertFalse(cond.has("targetValue"));
assertEquals("K_50", cond.path("rightField").asText());
assertTrue(cond.path("thresholdOverride").asBoolean(false));
assertEquals(50.0, cond.path("targetValue").asDouble(), 0.0001);
}
@Test
void preservesK55DirectInput() throws Exception {
JsonNode root = MAPPER.readTree("""
{
"type": "CONDITION",
"condition": {
"indicatorType": "RSI",
"conditionType": "CROSS_UP",
"leftField": "RSI_VALUE",
"rightField": "K_55",
"thresholdOverride": true,
"targetValue": 55
}
}
""");
JsonNode cond = StrategyConditionThresholdNormalizer.normalizeTree(root).path("condition");
assertEquals("K_55", cond.path("rightField").asText());
assertTrue(cond.path("thresholdOverride").asBoolean(false));
assertEquals(55.0, cond.path("targetValue").asDouble(), 0.0001);
}
@Test
void fillsTargetValueFromKFieldWhenMissing() throws Exception {
JsonNode root = MAPPER.readTree("""
{
"type": "CONDITION",
"condition": {
"indicatorType": "RSI",
"rightField": "K_55",
"thresholdOverride": false
}
}
""");
JsonNode cond = StrategyConditionThresholdNormalizer.normalizeTree(root).path("condition");
assertEquals("K_55", cond.path("rightField").asText());
assertTrue(cond.path("thresholdOverride").asBoolean(false));
assertEquals(55.0, cond.path("targetValue").asDouble(), 0.0001);
}
}