직접입력값 평가오류 수정
This commit is contained in:
@@ -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");
|
||||
|
||||
+13
-73
@@ -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,83 +43,24 @@ 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 (!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_")) {
|
||||
if (!field.startsWith("K_")) return;
|
||||
try {
|
||||
return Double.parseDouble(field.substring(2));
|
||||
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) {
|
||||
return null;
|
||||
// 비정상 K_ 필드는 그대로 둠
|
||||
}
|
||||
}
|
||||
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 문자열 보정 — 파싱 실패 시 원본 반환 */
|
||||
public static String normalizeJson(String json, com.fasterxml.jackson.databind.ObjectMapper mapper) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
+49
-7
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,94 +1,68 @@
|
||||
/** 정규화 로직 회귀 검증 — node scripts/test-threshold-normalize.mjs */
|
||||
function isThresholdOverridden(cond) {
|
||||
return cond.thresholdOverride === true;
|
||||
}
|
||||
function parseThresholdField(field) {
|
||||
if (!field?.startsWith('K_')) return null;
|
||||
const n = parseFloat(field.slice(2));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
const DEFAULT = { RSI: { over: 70, mid: 50, under: 30 } };
|
||||
function inferThresholdRoleForValue(value, indicatorType) {
|
||||
const key = indicatorType?.toUpperCase?.() ?? indicatorType;
|
||||
const th = DEFAULT[key] ?? {};
|
||||
const eps = 0.0001;
|
||||
if (th.over != null && Math.abs(th.over - value) < eps) return 'HL_OVER';
|
||||
if (th.mid != null && Math.abs(th.mid - value) < eps) return 'HL_MID';
|
||||
if (th.under != null && Math.abs(th.under - value) < eps) return 'HL_UNDER';
|
||||
return null;
|
||||
|
||||
function ensureDirectThresholdSnapshot(cond) {
|
||||
const indicatorType = cond.indicatorType?.toUpperCase?.() ?? cond.indicatorType;
|
||||
let next = { ...cond, indicatorType };
|
||||
for (const key of ['rightField', 'leftField']) {
|
||||
const f = next[key];
|
||||
if (!f?.startsWith('K_')) continue;
|
||||
const parsed = parseThresholdField(f);
|
||||
if (parsed == null) continue;
|
||||
if (next.targetValue == null || !Number.isFinite(next.targetValue)) {
|
||||
next = { ...next, targetValue: parsed, thresholdOverride: true };
|
||||
} else if (next.thresholdOverride !== true) {
|
||||
next = { ...next, thresholdOverride: true };
|
||||
}
|
||||
function inferFromK(field, indicatorType) {
|
||||
if (!field?.startsWith('K_')) return null;
|
||||
const val = parseFloat(field.slice(2));
|
||||
return inferThresholdRoleForValue(val, indicatorType);
|
||||
}
|
||||
function canonicalizeCondition(cond) {
|
||||
let next = { ...cond, indicatorType: cond.indicatorType?.toUpperCase?.() ?? cond.indicatorType };
|
||||
const targetNum = typeof next.targetValue === 'number' && Number.isFinite(next.targetValue)
|
||||
? next.targetValue
|
||||
: null;
|
||||
const fromField = parseThresholdField(next.rightField);
|
||||
const val = targetNum ?? fromField;
|
||||
if (val != null && Number.isFinite(val)) {
|
||||
const role = inferThresholdRoleForValue(val, next.indicatorType);
|
||||
if (role) {
|
||||
const { targetValue, ...rest } = next;
|
||||
return { ...rest, rightField: role, thresholdOverride: false };
|
||||
}
|
||||
}
|
||||
if (next.rightField?.startsWith('K_')) {
|
||||
const role = inferFromK(next.rightField, next.indicatorType);
|
||||
if (role) {
|
||||
const { targetValue, ...rest } = next;
|
||||
return { ...rest, rightField: role, thresholdOverride: false };
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
function isConditionCarrier(node) {
|
||||
return !!node.condition && (node.type === 'CONDITION' || node.type == null);
|
||||
}
|
||||
function normalizeNode(node) {
|
||||
let next = { ...node };
|
||||
if (isConditionCarrier(node)) {
|
||||
next = { ...node, type: node.type ?? 'CONDITION', condition: canonicalizeCondition(node.condition) };
|
||||
}
|
||||
if (next.children?.length) {
|
||||
next.children = next.children.map(normalizeNode);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 차트 hline mid=48 이어도 직접입력 50 → HL_MID (Ta4j 기본값 기준) */
|
||||
function setConditionThreshold(cond, value) {
|
||||
const role = inferThresholdRoleForValue(value, cond.indicatorType);
|
||||
if (role) {
|
||||
const { targetValue, ...rest } = cond;
|
||||
return { ...rest, rightField: role, thresholdOverride: false };
|
||||
function setDirectThreshold(cond, side, value) {
|
||||
const fieldKey = side === 'left' ? 'leftField' : 'rightField';
|
||||
return {
|
||||
...cond,
|
||||
[fieldKey]: `K_${value}`,
|
||||
targetValue: value,
|
||||
thresholdOverride: true,
|
||||
};
|
||||
}
|
||||
return { ...cond, rightField: `K_${value}`, targetValue: value, thresholdOverride: true };
|
||||
|
||||
function setConditionThreshold(cond, value, directInput) {
|
||||
if (directInput) return setDirectThreshold(cond, 'right', value);
|
||||
return setDirectThreshold(cond, 'right', value);
|
||||
}
|
||||
|
||||
let failed = 0;
|
||||
const cases = [
|
||||
{ type: 'CONDITION', condition: { indicatorType: 'RSI', rightField: 'K_50', thresholdOverride: true, targetValue: 50 } },
|
||||
{ condition: { indicatorType: 'RSI', rightField: 'K_50', thresholdOverride: true, targetValue: 50 } },
|
||||
{ type: 'TIMEFRAME', children: [{ condition: { indicatorType: 'RSI', rightField: 'K_50', thresholdOverride: true, targetValue: 50 } }] },
|
||||
];
|
||||
for (const c of cases) {
|
||||
const r = normalizeNode(c);
|
||||
const cond = r.condition ?? r.children?.[0]?.condition;
|
||||
const ok = cond?.rightField === 'HL_MID' && cond?.thresholdOverride === false;
|
||||
function assertOk(label, ok) {
|
||||
if (!ok) failed++;
|
||||
console.log(ok ? 'PASS' : 'FAIL', JSON.stringify(c).slice(0, 60), '=>', cond?.rightField, cond?.thresholdOverride);
|
||||
console.log(ok ? 'PASS' : 'FAIL', label);
|
||||
}
|
||||
|
||||
const direct = setConditionThreshold(
|
||||
{ indicatorType: 'RSI', rightField: 'HL_OVER', thresholdOverride: false },
|
||||
50,
|
||||
);
|
||||
const directOk = direct.rightField === 'HL_MID' && direct.thresholdOverride === false;
|
||||
if (!directOk) failed++;
|
||||
console.log(directOk ? 'PASS' : 'FAIL', 'setConditionThreshold(50) with chart mid≠50 =>', direct.rightField);
|
||||
const k55 = ensureDirectThresholdSnapshot({
|
||||
indicatorType: 'RSI',
|
||||
rightField: 'K_55',
|
||||
thresholdOverride: true,
|
||||
targetValue: 55,
|
||||
});
|
||||
assertOk('K_55 preserved', k55.rightField === 'K_55' && k55.targetValue === 55 && k55.thresholdOverride === true);
|
||||
|
||||
const k50 = ensureDirectThresholdSnapshot({
|
||||
indicatorType: 'RSI',
|
||||
rightField: 'K_50',
|
||||
thresholdOverride: true,
|
||||
targetValue: 50,
|
||||
});
|
||||
assertOk('K_50 preserved (not HL_MID)', k50.rightField === 'K_50' && k50.thresholdOverride === true);
|
||||
|
||||
const direct55 = setConditionThreshold({ indicatorType: 'RSI', rightField: 'HL_MID', thresholdOverride: false }, 55, true);
|
||||
assertOk('directInput 55 => K_55', direct55.rightField === 'K_55' && direct55.targetValue === 55);
|
||||
|
||||
const filled = ensureDirectThresholdSnapshot({ indicatorType: 'RSI', rightField: 'K_55', thresholdOverride: false });
|
||||
assertOk('K_55 fills targetValue', filled.targetValue === 55 && filled.thresholdOverride === true);
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
setConditionRightPeriod,
|
||||
setConditionThreshold,
|
||||
setConditionValuePeriod,
|
||||
canonicalizeConditionThreshold,
|
||||
ensureDirectThresholdSnapshot,
|
||||
usesValuePeriodField,
|
||||
} from '../../utils/conditionPeriods';
|
||||
import { compositeDisplayName, compositeElementLabel, COMPOSITE_INDICATOR_ITEMS, switchCompositeIndicatorType } from '../../utils/compositeIndicators';
|
||||
@@ -170,7 +170,7 @@ export default function ConditionNodeSettings({
|
||||
max={thresholdBounds.max}
|
||||
allowDecimal
|
||||
onChange={v => onChange(
|
||||
canonicalizeConditionThreshold(
|
||||
ensureDirectThresholdSnapshot(
|
||||
setConditionThreshold(condition, 'right', v, def, { directInput: true }),
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -205,38 +205,47 @@ export function thresholdField(value: number): string {
|
||||
return `K_${value}`;
|
||||
}
|
||||
|
||||
/** 직접입력 — K_ 숫자·targetValue·thresholdOverride 스냅샷 */
|
||||
export function setDirectThreshold(
|
||||
cond: ConditionDSL,
|
||||
side: 'left' | 'right',
|
||||
value: number,
|
||||
): ConditionDSL {
|
||||
const fieldKey = side === 'left' ? 'leftField' : 'rightField';
|
||||
return {
|
||||
...cond,
|
||||
[fieldKey]: thresholdField(value),
|
||||
targetValue: value,
|
||||
thresholdOverride: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 표준 hline 값(30/50/70 등)이면 K_*·targetValue 를 HL_* 로 통일.
|
||||
* 차트 커스텀 hline(def.hlThresh)과 무관 — Ta4j 기본 역할값 기준.
|
||||
* K_* 직접입력 스냅샷 보강 — targetValue 누락 시 필드명에서 복원.
|
||||
* HL_* 로 치환하지 않음 (직접입력 55는 평가 시 55 그대로).
|
||||
*/
|
||||
export function canonicalizeConditionThreshold(cond: ConditionDSL): ConditionDSL {
|
||||
export function ensureDirectThresholdSnapshot(cond: ConditionDSL): ConditionDSL {
|
||||
const indicatorType = cond.indicatorType?.toUpperCase?.() ?? cond.indicatorType;
|
||||
let next: ConditionDSL = { ...cond, indicatorType };
|
||||
|
||||
const rawTarget = next.targetValue;
|
||||
const targetNum = typeof rawTarget === 'number' && Number.isFinite(rawTarget) ? rawTarget : null;
|
||||
const fromField = parseThresholdField(next.rightField);
|
||||
const val = Number.isFinite(targetNum) ? targetNum : fromField;
|
||||
|
||||
if (val != null && Number.isFinite(val)) {
|
||||
const role = inferThresholdRoleForValue(val, indicatorType, {});
|
||||
if (role != null) {
|
||||
const { targetValue: _t, ...rest } = next;
|
||||
return { ...rest, rightField: role, thresholdOverride: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (next.rightField?.startsWith('K_')) {
|
||||
const role = inferThresholdSymbolFromLegacyExact(next.rightField, indicatorType, {});
|
||||
if (role != null) {
|
||||
const { targetValue: _t, ...rest } = next;
|
||||
return { ...rest, rightField: role, thresholdOverride: false };
|
||||
for (const key of ['rightField', 'leftField'] as const) {
|
||||
const f = next[key];
|
||||
if (!f?.startsWith('K_')) continue;
|
||||
const parsed = parseThresholdField(f);
|
||||
if (parsed == null) continue;
|
||||
if (next.targetValue == null || !Number.isFinite(next.targetValue)) {
|
||||
next = { ...next, targetValue: parsed, thresholdOverride: true };
|
||||
} else if (!isThresholdOverridden(next)) {
|
||||
next = { ...next, thresholdOverride: true };
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/** @deprecated ensureDirectThresholdSnapshot 사용 — HL_* 치환 없음 */
|
||||
export const canonicalizeConditionThreshold = ensureDirectThresholdSnapshot;
|
||||
|
||||
export function hasEditableThreshold(cond: ConditionDSL): boolean {
|
||||
return isThresholdFieldOrSymbol(cond.rightField)
|
||||
|| isThresholdFieldOrSymbol(cond.leftField)
|
||||
@@ -313,9 +322,7 @@ export function activateDirectThresholdInput(
|
||||
?? getChartReferenceThreshold(cond, side, inheritedField, def)
|
||||
?? parseThresholdField(side === 'right' ? cond.rightField : cond.leftField);
|
||||
if (value == null || !Number.isFinite(value)) return cond;
|
||||
return canonicalizeConditionThreshold(
|
||||
setConditionThreshold(cond, side, value, def),
|
||||
);
|
||||
return setDirectThreshold(cond, side, value);
|
||||
}
|
||||
|
||||
export function setConditionThreshold(
|
||||
@@ -326,20 +333,16 @@ export function setConditionThreshold(
|
||||
options?: SetConditionThresholdOptions,
|
||||
): ConditionDSL {
|
||||
const fieldKey = side === 'left' ? 'leftField' : 'rightField';
|
||||
|
||||
// 직접입력 — K_ 스냅샷 유지 (55 등 비표준값도 평가에 그대로 반영)
|
||||
if (options?.directInput) {
|
||||
return setDirectThreshold(cond, side, value);
|
||||
}
|
||||
|
||||
const current = cond[fieldKey];
|
||||
if (!isThresholdFieldOrSymbol(current)) {
|
||||
if (side === 'right' && INDICATORS_WITH_RIGHT_THRESHOLD_INPUT.has(cond.indicatorType)) {
|
||||
const matchedRole = inferThresholdRoleForValue(value, cond.indicatorType, {});
|
||||
if (matchedRole != null) {
|
||||
const { targetValue: _t, ...rest } = cond;
|
||||
return { ...rest, [fieldKey]: matchedRole, thresholdOverride: false };
|
||||
}
|
||||
return {
|
||||
...cond,
|
||||
[fieldKey]: thresholdField(value),
|
||||
targetValue: value,
|
||||
thresholdOverride: true,
|
||||
};
|
||||
return setDirectThreshold(cond, side, value);
|
||||
}
|
||||
return cond;
|
||||
}
|
||||
@@ -351,33 +354,13 @@ export function setConditionThreshold(
|
||||
? getChartReferenceThreshold(cond, side, inheritedField, def)
|
||||
: null;
|
||||
|
||||
// 30/50/70 등 표준 hline — 차트 커스텀값과 무관하게 HL_* 로 통일 (직접입력·프리셋·평가 일치)
|
||||
const matchedRole = inferThresholdRoleForValue(value, cond.indicatorType, {});
|
||||
if (matchedRole != null) {
|
||||
// 드롭다운 hline 프리셋(중앙선 등)과 차트 기준값이 일치할 때만 상속
|
||||
if (chartRef != null && Math.abs(chartRef - value) < 0.0001 && isThresholdSymbol(inheritedField)) {
|
||||
const { targetValue: _t, ...rest } = cond;
|
||||
return { ...rest, [fieldKey]: matchedRole, thresholdOverride: false };
|
||||
return { ...rest, [fieldKey]: inheritedField, thresholdOverride: false };
|
||||
}
|
||||
|
||||
if (!options?.directInput && chartRef != null && Math.abs(chartRef - value) < 0.0001) {
|
||||
const role = isThresholdSymbol(inheritedField)
|
||||
? inheritedField
|
||||
: (side === 'right' ? THRESHOLD_HL_OVER : inheritedField);
|
||||
const { targetValue: _t, ...rest } = cond;
|
||||
return { ...rest, [fieldKey]: role, thresholdOverride: false };
|
||||
}
|
||||
|
||||
const fallbackRole = inferThresholdRoleForValue(value, cond.indicatorType, {});
|
||||
if (fallbackRole != null) {
|
||||
const { targetValue: _t, ...rest } = cond;
|
||||
return { ...rest, [fieldKey]: fallbackRole, thresholdOverride: false };
|
||||
}
|
||||
|
||||
return {
|
||||
...cond,
|
||||
[fieldKey]: thresholdField(value),
|
||||
targetValue: value,
|
||||
thresholdOverride: true,
|
||||
};
|
||||
return setDirectThreshold(cond, side, value);
|
||||
}
|
||||
|
||||
export function setConditionValuePeriod(
|
||||
|
||||
@@ -3,35 +3,21 @@ import type { ConditionDSL, LogicNode } from './strategyTypes';
|
||||
import type { DefType } from './strategyEditorShared';
|
||||
import { getDefaultConditionFields } from './strategyEditorShared';
|
||||
import {
|
||||
canonicalizeConditionThreshold,
|
||||
ensureDirectThresholdSnapshot,
|
||||
isThresholdOverridden,
|
||||
isValuePeriodOverridden,
|
||||
parseThresholdField,
|
||||
usesValuePeriodField,
|
||||
VALUE_FIELD_PREFIX,
|
||||
} from './conditionPeriods';
|
||||
import {
|
||||
defaultInheritedThresholdField,
|
||||
inferThresholdRoleForValue,
|
||||
inferThresholdSymbolFromLegacy,
|
||||
inferThresholdSymbolFromLegacyExact,
|
||||
isThresholdFieldOrSymbol,
|
||||
isThresholdSymbol,
|
||||
THRESHOLD_HL_MID,
|
||||
THRESHOLD_HL_OVER,
|
||||
} from './thresholdSymbols';
|
||||
import { repairPriceExtremePairForest } from './priceExtremeBreakoutPair';
|
||||
|
||||
function coerceTargetValue(raw: unknown): number | null {
|
||||
if (raw == null) return null;
|
||||
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null;
|
||||
if (typeof raw === 'string') {
|
||||
const n = parseFloat(raw.trim());
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isConditionCarrier(node: LogicNode): node is LogicNode & { condition: ConditionDSL } {
|
||||
return !!node.condition && (node.type === 'CONDITION' || node.type == null || node.type === undefined);
|
||||
}
|
||||
@@ -41,22 +27,8 @@ function migrateConditionThreshold(
|
||||
def: DefType,
|
||||
signalType: 'buy' | 'sell',
|
||||
): ConditionDSL {
|
||||
// 직접입력(K_55 등) — 숫자 스냅샷 유지, HL_* 로 접지 않음
|
||||
if (isThresholdOverridden(cond)) {
|
||||
const val = coerceTargetValue(cond.targetValue) ?? parseThresholdField(cond.rightField);
|
||||
if (val != null) {
|
||||
const role = inferThresholdRoleForValue(val, cond.indicatorType, {});
|
||||
if (role != null) {
|
||||
const { targetValue: _t, ...rest } = cond;
|
||||
return migrateConditionPeriod({ ...rest, rightField: role, thresholdOverride: false });
|
||||
}
|
||||
}
|
||||
if (cond.rightField?.startsWith('K_')) {
|
||||
const role = inferThresholdSymbolFromLegacyExact(cond.rightField, cond.indicatorType, {});
|
||||
if (role != null) {
|
||||
const { targetValue: _t, ...rest } = cond;
|
||||
return migrateConditionPeriod({ ...rest, rightField: role, thresholdOverride: false });
|
||||
}
|
||||
}
|
||||
return cond;
|
||||
}
|
||||
|
||||
@@ -138,9 +110,9 @@ export function migrateLogicRootForEditor(
|
||||
return repairPriceExtremePairForest(migrated.root, migrated.orphans);
|
||||
}
|
||||
|
||||
/** 저장·표시·평가 공통 — K_50 등을 HL_MID 로 접기 */
|
||||
/** 저장·표시·평가 공통 — K_* 직접입력 스냅샷 보강 (HL_* 치환 없음) */
|
||||
export function normalizeConditionForPersistence(cond: ConditionDSL): ConditionDSL {
|
||||
let next = canonicalizeConditionThreshold(cond);
|
||||
let next = ensureDirectThresholdSnapshot(cond);
|
||||
|
||||
if (!isValuePeriodOverridden(next)) {
|
||||
const prefix = VALUE_FIELD_PREFIX[next.indicatorType];
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
getCompositeFieldOpts,
|
||||
getCompositeLeftCandleType,
|
||||
getCompositeRightCandleType,
|
||||
canonicalizeConditionThreshold,
|
||||
ensureDirectThresholdSnapshot,
|
||||
parseThresholdField,
|
||||
} from './conditionPeriods';
|
||||
import { formatStrategyCandleLabel } from './strategyStartNodes';
|
||||
@@ -154,7 +154,7 @@ function describeCondition(
|
||||
signalType: 'buy' | 'sell',
|
||||
def: DefType,
|
||||
): string {
|
||||
const cond = canonicalizeConditionThreshold(raw);
|
||||
const cond = ensureDirectThresholdSnapshot(raw);
|
||||
const ind = cond.indicatorType;
|
||||
const ct = cond.conditionType;
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ import {
|
||||
import ComboFieldSelect from '../components/strategyEditor/ComboFieldSelect';
|
||||
import {
|
||||
activateDirectThresholdInput,
|
||||
canonicalizeConditionThreshold,
|
||||
ensureDirectThresholdSnapshot,
|
||||
getCompositeFieldOpts,
|
||||
getCompositePeriodPresetOptions,
|
||||
getConditionRightPeriod,
|
||||
@@ -924,7 +924,7 @@ export function resolveFieldOptionValue(indicatorType: string, field?: string):
|
||||
export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string => {
|
||||
if (!node) return '';
|
||||
if (node.type === 'CONDITION' && node.condition) {
|
||||
const c = canonicalizeConditionThreshold(normalizeCompositeCondition(node.condition));
|
||||
const c = ensureDirectThresholdSnapshot(normalizeCompositeCondition(node.condition));
|
||||
const indName = getStrategyIndicatorDisplayName(c.indicatorType);
|
||||
const opts = getFieldOpts(c.indicatorType, 'buy', DEF, c);
|
||||
const C = condLabel(c.conditionType);
|
||||
@@ -1155,7 +1155,7 @@ export const CondEditor: React.FC<CondEditorProps> = ({
|
||||
const handleRight = (v: string) => {
|
||||
if (isThresholdSymbol(v)) {
|
||||
const { targetValue: _t, ...rest } = normalized;
|
||||
onChange(canonicalizeConditionThreshold({ ...rest, rightField: v, thresholdOverride: false }));
|
||||
onChange(ensureDirectThresholdSnapshot({ ...rest, rightField: v, thresholdOverride: false }));
|
||||
return;
|
||||
}
|
||||
const thresholds: Record<string,number> = {
|
||||
@@ -1177,7 +1177,7 @@ export const CondEditor: React.FC<CondEditorProps> = ({
|
||||
if (priorP != null && isPriceExtremeIndicator(normalized.indicatorType)) {
|
||||
upd = syncPriceExtremeSimpleFields({ ...upd, period: priorP });
|
||||
}
|
||||
onChange(canonicalizeConditionThreshold(upd));
|
||||
onChange(ensureDirectThresholdSnapshot(upd));
|
||||
};
|
||||
|
||||
if (normalized.composite) {
|
||||
@@ -1443,7 +1443,7 @@ export const CondEditor: React.FC<CondEditorProps> = ({
|
||||
));
|
||||
}}
|
||||
onCustomNumberChange={v => onChange(
|
||||
canonicalizeConditionThreshold(
|
||||
ensureDirectThresholdSnapshot(
|
||||
setConditionThreshold(normalized, 'right', v, def, { directInput: true }),
|
||||
),
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user