직접입력값 평가오류
This commit is contained in:
@@ -222,14 +222,8 @@ public class LiveConditionStatusService {
|
|||||||
Map<String, Map<String, Object>> params,
|
Map<String, Map<String, Object>> params,
|
||||||
Map<String, Map<String, Object>> visual) {
|
Map<String, Map<String, Object>> visual) {
|
||||||
try {
|
try {
|
||||||
JsonNode buyDslRaw = timeframeNormalizer.normalize(
|
JsonNode buyDslRaw = loadStrategyDsl(strategy.getBuyConditionJson(), strategy.getName());
|
||||||
objectMapper.readTree(
|
JsonNode sellDslRaw = loadStrategyDsl(strategy.getSellConditionJson(), strategy.getName());
|
||||||
strategy.getBuyConditionJson() != null ? strategy.getBuyConditionJson() : "null"),
|
|
||||||
strategy.getName());
|
|
||||||
JsonNode sellDslRaw = timeframeNormalizer.normalize(
|
|
||||||
objectMapper.readTree(
|
|
||||||
strategy.getSellConditionJson() != null ? strategy.getSellConditionJson() : "null"),
|
|
||||||
strategy.getName());
|
|
||||||
|
|
||||||
String primaryTf = OhlcvBarSeriesSupport.normalizeTf(chartTimeframe);
|
String primaryTf = OhlcvBarSeriesSupport.normalizeTf(chartTimeframe);
|
||||||
JsonNode buyDsl = timeframeNormalizer.remapForChartTimeframe(buyDslRaw, primaryTf);
|
JsonNode buyDsl = timeframeNormalizer.remapForChartTimeframe(buyDslRaw, primaryTf);
|
||||||
@@ -396,8 +390,9 @@ public class LiveConditionStatusService {
|
|||||||
Long barTimeSec) {
|
Long barTimeSec) {
|
||||||
if (conditionJson == null || conditionJson.isBlank()) return null;
|
if (conditionJson == null || conditionJson.isBlank()) return null;
|
||||||
try {
|
try {
|
||||||
|
String normalized = StrategyConditionThresholdNormalizer.normalizeJson(conditionJson, objectMapper);
|
||||||
com.fasterxml.jackson.databind.JsonNode dsl =
|
com.fasterxml.jackson.databind.JsonNode dsl =
|
||||||
objectMapper.readTree(conditionJson);
|
objectMapper.readTree(normalized);
|
||||||
if (dsl == null || dsl.isNull()) return null;
|
if (dsl == null || dsl.isNull()) return null;
|
||||||
|
|
||||||
// DSL 루트의 주 시간봉 추출 (TIMEFRAME 노드가 있으면 해당 봉 사용)
|
// DSL 루트의 주 시간봉 추출 (TIMEFRAME 노드가 있으면 해당 봉 사용)
|
||||||
@@ -458,12 +453,22 @@ public class LiveConditionStatusService {
|
|||||||
List<PendingCond> out) {
|
List<PendingCond> out) {
|
||||||
if (json == null || json.isBlank()) return;
|
if (json == null || json.isBlank()) return;
|
||||||
try {
|
try {
|
||||||
walk(objectMapper.readTree(json), timeframe, side, out, new HashSet<>());
|
String normalized = StrategyConditionThresholdNormalizer.normalizeJson(json, objectMapper);
|
||||||
|
walk(objectMapper.readTree(normalized), timeframe, side, out, new HashSet<>());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[LiveCondition] JSON walk fail: {}", e.getMessage());
|
log.warn("[LiveCondition] JSON walk fail: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private JsonNode loadStrategyDsl(String json, String strategyName) throws Exception {
|
||||||
|
JsonNode root = (json == null || json.isBlank())
|
||||||
|
? objectMapper.readTree("null")
|
||||||
|
: objectMapper.readTree(json);
|
||||||
|
return timeframeNormalizer.normalize(
|
||||||
|
StrategyConditionThresholdNormalizer.normalizeTree(root),
|
||||||
|
strategyName);
|
||||||
|
}
|
||||||
|
|
||||||
private void walk(JsonNode node, String timeframe, String side,
|
private void walk(JsonNode node, String timeframe, String side,
|
||||||
List<PendingCond> out, Set<String> seen) {
|
List<PendingCond> out, Set<String> seen) {
|
||||||
if (node == null || node.isNull()) return;
|
if (node == null || node.isNull()) return;
|
||||||
@@ -718,14 +723,8 @@ public class LiveConditionStatusService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
String strategyName = strategy.getName();
|
String strategyName = strategy.getName();
|
||||||
JsonNode buyDslRaw = timeframeNormalizer.normalize(
|
JsonNode buyDslRaw = loadStrategyDsl(strategy.getBuyConditionJson(), strategyName);
|
||||||
objectMapper.readTree(
|
JsonNode sellDslRaw = loadStrategyDsl(strategy.getSellConditionJson(), strategyName);
|
||||||
strategy.getBuyConditionJson() != null ? strategy.getBuyConditionJson() : "null"),
|
|
||||||
strategyName);
|
|
||||||
JsonNode sellDslRaw = timeframeNormalizer.normalize(
|
|
||||||
objectMapper.readTree(
|
|
||||||
strategy.getSellConditionJson() != null ? strategy.getSellConditionJson() : "null"),
|
|
||||||
strategyName);
|
|
||||||
|
|
||||||
String primaryTf = OhlcvBarSeriesSupport.normalizeTf(chartTimeframe);
|
String primaryTf = OhlcvBarSeriesSupport.normalizeTf(chartTimeframe);
|
||||||
JsonNode buyDsl = timeframeNormalizer.remapForChartTimeframe(buyDslRaw, primaryTf);
|
JsonNode buyDsl = timeframeNormalizer.remapForChartTimeframe(buyDslRaw, primaryTf);
|
||||||
|
|||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
package com.goldenchart.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전략 조건 DSL — K_50 등 직접입력 스냅샷을 hline 역할(HL_MID 등)로 정규화.
|
||||||
|
* 프론트 normalizeConditionForPersistence 와 동일 의도.
|
||||||
|
*/
|
||||||
|
public final class StrategyConditionThresholdNormalizer {
|
||||||
|
|
||||||
|
private StrategyConditionThresholdNormalizer() {}
|
||||||
|
|
||||||
|
public static JsonNode normalizeTree(JsonNode root) {
|
||||||
|
if (root == null || root.isNull() || !root.isObject()) return root;
|
||||||
|
ObjectNode copy = root.deepCopy();
|
||||||
|
walk(copy);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void walk(JsonNode node) {
|
||||||
|
if (node == null || !node.isObject()) return;
|
||||||
|
|
||||||
|
JsonNode cond = node.get("condition");
|
||||||
|
if (cond != null && cond.isObject()) {
|
||||||
|
normalizeCondition((ObjectNode) cond);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode children = node.get("children");
|
||||||
|
if (children != null && children.isArray()) {
|
||||||
|
for (JsonNode c : children) walk(c);
|
||||||
|
}
|
||||||
|
JsonNode child = node.get("child");
|
||||||
|
if (child != null) walk(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void normalizeCondition(ObjectNode cond) {
|
||||||
|
normalizeThresholdField(cond, "rightField");
|
||||||
|
normalizeThresholdField(cond, "leftField");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void normalizeThresholdField(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(cond.path("indicatorType").asText(""), 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(cond.path("indicatorType").asText(""), 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 문자열 보정 — 파싱 실패 시 원본 반환 */
|
||||||
|
public static String normalizeJson(String json, com.fasterxml.jackson.databind.ObjectMapper mapper) {
|
||||||
|
if (json == null || json.isBlank()) return json;
|
||||||
|
try {
|
||||||
|
JsonNode root = mapper.readTree(json);
|
||||||
|
JsonNode normalized = normalizeTree(root);
|
||||||
|
return mapper.writeValueAsString(normalized);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,11 +66,15 @@ public class StrategyService {
|
|||||||
}
|
}
|
||||||
entity.setBuyConditionJson(
|
entity.setBuyConditionJson(
|
||||||
buyJson != null
|
buyJson != null
|
||||||
? dslTimeframeNormalizer.normalizeJson(buyJson, strategyName)
|
? dslTimeframeNormalizer.normalizeJson(
|
||||||
|
StrategyConditionThresholdNormalizer.normalizeJson(buyJson, objectMapper),
|
||||||
|
strategyName)
|
||||||
: null);
|
: null);
|
||||||
entity.setSellConditionJson(
|
entity.setSellConditionJson(
|
||||||
sellJson != null
|
sellJson != null
|
||||||
? dslTimeframeNormalizer.normalizeJson(sellJson, strategyName)
|
? dslTimeframeNormalizer.normalizeJson(
|
||||||
|
StrategyConditionThresholdNormalizer.normalizeJson(sellJson, objectMapper),
|
||||||
|
strategyName)
|
||||||
: null);
|
: null);
|
||||||
if (dto.getFlowLayout() != null && !dto.getFlowLayout().isNull()) {
|
if (dto.getFlowLayout() != null && !dto.getFlowLayout().isNull()) {
|
||||||
entity.setFlowLayoutJson(objectMapper.writeValueAsString(dto.getFlowLayout()));
|
entity.setFlowLayoutJson(objectMapper.writeValueAsString(dto.getFlowLayout()));
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package com.goldenchart.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
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;
|
||||||
|
|
||||||
|
class StrategyConditionThresholdNormalizerTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void normalizesK50OverrideToHlMid() throws Exception {
|
||||||
|
JsonNode root = MAPPER.readTree("""
|
||||||
|
{
|
||||||
|
"type": "CONDITION",
|
||||||
|
"condition": {
|
||||||
|
"indicatorType": "RSI",
|
||||||
|
"conditionType": "CROSS_UP",
|
||||||
|
"leftField": "RSI_VALUE_9",
|
||||||
|
"rightField": "K_50",
|
||||||
|
"period": 9,
|
||||||
|
"valuePeriodOverride": true,
|
||||||
|
"thresholdOverride": true,
|
||||||
|
"targetValue": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
JsonNode normalized = StrategyConditionThresholdNormalizer.normalizeTree(root);
|
||||||
|
JsonNode cond = normalized.path("condition");
|
||||||
|
|
||||||
|
assertEquals("HL_MID", cond.path("rightField").asText());
|
||||||
|
assertFalse(cond.path("thresholdOverride").asBoolean(true));
|
||||||
|
assertFalse(cond.has("targetValue"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -266,7 +266,7 @@ export function getConditionThreshold(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type SetConditionThresholdOptions = {
|
export type SetConditionThresholdOptions = {
|
||||||
/** true: 직접입력 — K_+targetValue+thresholdOverride 유지 (프리셋 HL_* 로 접지 않음) */
|
/** true: 직접입력 모드 — hline 역할과 일치하지 않는 값만 K_ 스냅샷 유지 */
|
||||||
directInput?: boolean;
|
directInput?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -294,6 +294,13 @@ export function setConditionThreshold(
|
|||||||
const current = cond[fieldKey];
|
const current = cond[fieldKey];
|
||||||
if (!isThresholdFieldOrSymbol(current)) {
|
if (!isThresholdFieldOrSymbol(current)) {
|
||||||
if (side === 'right' && INDICATORS_WITH_RIGHT_THRESHOLD_INPUT.has(cond.indicatorType)) {
|
if (side === 'right' && INDICATORS_WITH_RIGHT_THRESHOLD_INPUT.has(cond.indicatorType)) {
|
||||||
|
const matchedRole = def
|
||||||
|
? inferThresholdRoleForValue(value, cond.indicatorType, def.hlThresh)
|
||||||
|
: null;
|
||||||
|
if (matchedRole != null) {
|
||||||
|
const { targetValue: _t, ...rest } = cond;
|
||||||
|
return { ...rest, [fieldKey]: matchedRole, thresholdOverride: false };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...cond,
|
...cond,
|
||||||
[fieldKey]: thresholdField(value),
|
[fieldKey]: thresholdField(value),
|
||||||
|
|||||||
@@ -26,7 +26,24 @@ function migrateConditionThreshold(
|
|||||||
def: DefType,
|
def: DefType,
|
||||||
signalType: 'buy' | 'sell',
|
signalType: 'buy' | 'sell',
|
||||||
): ConditionDSL {
|
): ConditionDSL {
|
||||||
if (isThresholdOverridden(cond)) return cond;
|
if (isThresholdOverridden(cond)) {
|
||||||
|
const val = cond.targetValue ?? parseThresholdField(cond.rightField);
|
||||||
|
if (val != null && Number.isFinite(val)) {
|
||||||
|
const role = inferThresholdRoleForValue(val, cond.indicatorType, def.hlThresh);
|
||||||
|
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, def.hlThresh);
|
||||||
|
if (role != null) {
|
||||||
|
const { targetValue: _t, ...rest } = cond;
|
||||||
|
return migrateConditionPeriod({ ...rest, rightField: role, thresholdOverride: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cond;
|
||||||
|
}
|
||||||
|
|
||||||
let rightField = cond.rightField;
|
let rightField = cond.rightField;
|
||||||
if (rightField?.startsWith('K_')) {
|
if (rightField?.startsWith('K_')) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { LogicNode } from './strategyTypes';
|
|||||||
import type { StrategyDto } from './backendApi';
|
import type { StrategyDto } from './backendApi';
|
||||||
import { extractVirtualConditions } from './virtualStrategyConditions';
|
import { extractVirtualConditions } from './virtualStrategyConditions';
|
||||||
import { decodeConditionForEditor, encodeConditionForSave } from './strategyConditionSerde';
|
import { decodeConditionForEditor, encodeConditionForSave } from './strategyConditionSerde';
|
||||||
|
import { normalizeLogicRootForPersistence } from './strategyConditionNormalize';
|
||||||
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
|
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
|
||||||
|
|
||||||
export function asLogicNode(raw: unknown): LogicNode | null {
|
export function asLogicNode(raw: unknown): LogicNode | null {
|
||||||
@@ -34,8 +35,8 @@ export function hydrateStrategyDto(strategy: StrategyDto): StrategyDto {
|
|||||||
const sell = asLogicNode(strategy.sellCondition);
|
const sell = asLogicNode(strategy.sellCondition);
|
||||||
let next: StrategyDto = {
|
let next: StrategyDto = {
|
||||||
...strategy,
|
...strategy,
|
||||||
buyCondition: buy ?? undefined,
|
buyCondition: normalizeLogicRootForPersistence(buy) ?? buy ?? undefined,
|
||||||
sellCondition: sell ?? undefined,
|
sellCondition: normalizeLogicRootForPersistence(sell) ?? sell ?? undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (extractVirtualConditions(next).length > 0) return next;
|
if (extractVirtualConditions(next).length > 0) return next;
|
||||||
|
|||||||
Reference in New Issue
Block a user