전략 시간봉 오류 수정
This commit is contained in:
@@ -42,20 +42,7 @@ public class StrategyDslTimeframeNormalizer {
|
||||
if (root == null || root.isNull()) return root;
|
||||
|
||||
if ("TIMEFRAME".equals(root.path("type").asText(""))) {
|
||||
// START 다중 분봉(candleTypes) — stale 1m 제거 후 candleType 동기화
|
||||
if (hasCandleTypesArray(root)) {
|
||||
return syncTimeframePrimary(reconcileStartCandleTypes(root));
|
||||
}
|
||||
String current = LiveStrategyTimeframeService.normalize(root.path("candleType").asText("1m"));
|
||||
// 편집기에서 명시한 단일 분봉(3m, 5m 등)은 조건 노드 기본 1m 추론으로 덮어쓰지 않음
|
||||
if (!"1m".equals(current)) {
|
||||
return root;
|
||||
}
|
||||
String expected = inferPrimaryCandleType(root, strategyName);
|
||||
if (!expected.equals(current)) {
|
||||
return copyTimeframeWithCandleType(root, expected);
|
||||
}
|
||||
return root;
|
||||
return writeResolvedTimeframe(root, strategyName);
|
||||
}
|
||||
|
||||
if (hasTimeframeScope(root)) return root;
|
||||
@@ -65,6 +52,9 @@ public class StrategyDslTimeframeNormalizer {
|
||||
wrapped.put("id", UUID.randomUUID().toString());
|
||||
wrapped.put("type", "TIMEFRAME");
|
||||
wrapped.put("candleType", primary);
|
||||
ArrayNode candleTypes = objectMapper.createArrayNode();
|
||||
candleTypes.add(primary);
|
||||
wrapped.set("candleTypes", candleTypes);
|
||||
ArrayNode children = objectMapper.createArrayNode();
|
||||
children.add(root);
|
||||
wrapped.set("children", children);
|
||||
@@ -111,6 +101,9 @@ public class StrategyDslTimeframeNormalizer {
|
||||
}
|
||||
|
||||
private static boolean hasExplicitStartTimeframe(JsonNode buyTf) {
|
||||
Set<String> resolved = resolveStartCandleTypes(buyTf);
|
||||
if (resolved.size() > 1) return true;
|
||||
if (resolved.size() == 1 && !resolved.contains("1m")) return true;
|
||||
if (hasCandleTypesArray(buyTf)) return true;
|
||||
String ct = LiveStrategyTimeframeService.normalize(buyTf.path("candleType").asText("1m"));
|
||||
return !"1m".equals(ct);
|
||||
@@ -274,17 +267,57 @@ public class StrategyDslTimeframeNormalizer {
|
||||
}
|
||||
|
||||
/**
|
||||
* START candleTypes 에 남은 레거시 1m 제거.
|
||||
* <ul>
|
||||
* <li>[1m, 5m] + 조건 leftCandleType 5m → [5m] (편집기 5m만 선택·1m 기본값 잔존)</li>
|
||||
* <li>[1m, 3m, 5m] 다중 OR 선택은 유지</li>
|
||||
* <li>조건에 1m 명시 시 유지</li>
|
||||
* </ul>
|
||||
* START 평가 분봉 해석 — 조건 left/rightCandleType · START candleTypes/candleType 통합.
|
||||
* 1m·3m·5m·15m·1h 등 모든 분봉에 동일 규칙 적용.
|
||||
*/
|
||||
public static Set<String> resolveStartCandleTypes(JsonNode timeframeNode) {
|
||||
ObjectNode reconciled = reconcileStartCandleTypesStatic(timeframeNode);
|
||||
Set<String> fromStart = readStartCandleTypesFromNode(reconciled);
|
||||
JsonNode subtree = firstTimeframeChild(timeframeNode);
|
||||
Set<String> fromConditions = collectExplicitCandleTypes(subtree);
|
||||
|
||||
if (fromConditions.isEmpty()) {
|
||||
return fromStart;
|
||||
}
|
||||
|
||||
if (fromConditions.size() == 1) {
|
||||
String required = fromConditions.iterator().next();
|
||||
// 레거시 [1m, X] — 조건이 X 단일 분봉이면 X만
|
||||
if (fromStart.size() == 2 && fromStart.contains("1m") && fromStart.contains(required)) {
|
||||
return Set.of(required);
|
||||
}
|
||||
// START 1m 기본값만 있고 조건은 다른 분봉
|
||||
if (isLegacyDefaultStartOnly(fromStart) && !"1m".equals(required)) {
|
||||
return Set.of(required);
|
||||
}
|
||||
// 의도적 다중 OR [1m,3m,5m] 또는 [3m,5m] — START 배열 유지
|
||||
if (fromStart.size() >= 2 && fromStart.contains(required)) {
|
||||
return fromStart;
|
||||
}
|
||||
if (!fromStart.equals(Set.of(required))) {
|
||||
return Set.of(required);
|
||||
}
|
||||
return fromStart;
|
||||
}
|
||||
|
||||
// 조건에 복수 분봉 명시 — START가 상위집합이면 START 유지 (다중 OR)
|
||||
if (fromStart.containsAll(fromConditions) && fromStart.size() >= fromConditions.size()) {
|
||||
return fromStart;
|
||||
}
|
||||
return fromConditions;
|
||||
}
|
||||
|
||||
private ObjectNode writeResolvedTimeframe(JsonNode root, String strategyName) {
|
||||
Set<String> resolved = resolveStartCandleTypes(root);
|
||||
if (resolved.isEmpty()) {
|
||||
resolved = Set.of(inferPrimaryCandleType(root, strategyName));
|
||||
}
|
||||
return copyTimeframeWithCandleTypes(root, resolved);
|
||||
}
|
||||
|
||||
private static Set<String> readStartCandleTypesFromNode(JsonNode tf) {
|
||||
Set<String> out = new LinkedHashSet<>();
|
||||
JsonNode arr = reconciled.path("candleTypes");
|
||||
JsonNode arr = tf.path("candleTypes");
|
||||
if (arr.isArray() && !arr.isEmpty()) {
|
||||
for (JsonNode t : arr) {
|
||||
String ct = LiveStrategyTimeframeService.normalize(t.asText(""));
|
||||
@@ -292,11 +325,14 @@ public class StrategyDslTimeframeNormalizer {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
out.add(LiveStrategyTimeframeService.normalize(
|
||||
reconciled.path("candleType").asText("1m")));
|
||||
out.add(LiveStrategyTimeframeService.normalize(tf.path("candleType").asText("1m")));
|
||||
return out;
|
||||
}
|
||||
|
||||
private static boolean isLegacyDefaultStartOnly(Set<String> fromStart) {
|
||||
return fromStart.size() == 1 && fromStart.contains("1m");
|
||||
}
|
||||
|
||||
private ObjectNode reconcileStartCandleTypes(JsonNode tf) {
|
||||
return reconcileStartCandleTypesStatic(tf);
|
||||
}
|
||||
@@ -349,9 +385,23 @@ public class StrategyDslTimeframeNormalizer {
|
||||
return copy;
|
||||
}
|
||||
|
||||
private ObjectNode copyTimeframeWithCandleType(JsonNode tf, String candleType) {
|
||||
private ObjectNode copyTimeframeWithCandleTypes(JsonNode tf, Set<String> types) {
|
||||
ObjectNode copy = tf.deepCopy();
|
||||
copy.put("candleType", LiveStrategyTimeframeService.normalize(candleType));
|
||||
ArrayNode arr = JsonNodeFactory.instance.arrayNode();
|
||||
List<String> ordered = new ArrayList<>(types);
|
||||
for (String ct : ordered) {
|
||||
arr.add(LiveStrategyTimeframeService.normalize(ct));
|
||||
}
|
||||
if (arr.isEmpty()) {
|
||||
arr.add("1m");
|
||||
}
|
||||
copy.set("candleTypes", arr);
|
||||
copy.put("candleType", arr.get(0).asText());
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** @deprecated 단일 분봉 — {@link #copyTimeframeWithCandleTypes} 사용 */
|
||||
private ObjectNode copyTimeframeWithCandleType(JsonNode tf, String candleType) {
|
||||
return copyTimeframeWithCandleTypes(tf, Set.of(candleType));
|
||||
}
|
||||
}
|
||||
|
||||
+40
@@ -10,6 +10,7 @@ import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -159,6 +160,45 @@ class StrategyConditionTimeframeServiceTest {
|
||||
assertFalse(service.usesTimeframe(5L, "1m"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectForStrategy_staleStartUsesConditionTimeframeForAnyTf() {
|
||||
for (String tf : List.of("3m", "5m", "15m", "1h")) {
|
||||
GcStrategy strategy = new GcStrategy();
|
||||
strategy.setId(10L);
|
||||
strategy.setName("tf test " + tf);
|
||||
strategy.setBuyConditionJson("""
|
||||
{
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "1m",
|
||||
"candleTypes": ["1m", "%s"],
|
||||
"children": [{
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "RSI",
|
||||
"leftCandleType": "%s",
|
||||
"rightCandleType": "%s"
|
||||
}
|
||||
}]
|
||||
}
|
||||
""".formatted(tf, tf, tf));
|
||||
strategy.setSellConditionJson(null);
|
||||
when(strategyRepo.findById(10L)).thenReturn(Optional.of(strategy));
|
||||
when(dslNormalizer.alignSellStartTimeframeToBuy(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()))
|
||||
.thenAnswer(inv -> inv.getArgument(1));
|
||||
when(dslNormalizer.normalizeJson(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()))
|
||||
.thenAnswer(inv -> {
|
||||
String json = inv.getArgument(0);
|
||||
String name = inv.getArgument(1);
|
||||
return new StrategyDslTimeframeNormalizer(objectMapper).normalizeJson(json, name);
|
||||
});
|
||||
|
||||
Set<String> tfs = service.collectForStrategy(10L);
|
||||
assertEquals(Set.of(tf), tfs, "expected only " + tf);
|
||||
assertFalse(service.usesTimeframe(10L, "1m"), "1m should not be used for " + tf);
|
||||
service.invalidate(10L);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectForStrategy_stale1m5mArrayUses5mOnlyWhenConditionsSay5m() {
|
||||
GcStrategy strategy = new GcStrategy();
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
@@ -75,6 +76,26 @@ class StrategyDslTimeframeNormalizerTest {
|
||||
assertEquals("5m", root.path("candleType").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveStartCandleTypes_meaningfulOverridesStaleStart1m() throws Exception {
|
||||
String json = """
|
||||
{
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "1m",
|
||||
"children": [{
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "CCI",
|
||||
"leftCandleType": "5m",
|
||||
"rightCandleType": "5m"
|
||||
}
|
||||
}]
|
||||
}
|
||||
""";
|
||||
JsonNode root = objectMapper.readTree(json);
|
||||
assertEquals(Set.of("5m"), StrategyDslTimeframeNormalizer.resolveStartCandleTypes(root));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveStartCandleTypes_dropsStale1mPair() throws Exception {
|
||||
String json = """
|
||||
@@ -95,6 +116,51 @@ class StrategyDslTimeframeNormalizerTest {
|
||||
assertEquals(Set.of("5m"), StrategyDslTimeframeNormalizer.resolveStartCandleTypes(root));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveStartCandleTypes_resolves3mAnd15mAnd1h() throws Exception {
|
||||
for (String tf : List.of("3m", "15m", "1h")) {
|
||||
String json = """
|
||||
{
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "1m",
|
||||
"children": [{
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "RSI",
|
||||
"leftCandleType": "%s",
|
||||
"rightCandleType": "%s"
|
||||
}
|
||||
}]
|
||||
}
|
||||
""".formatted(tf, tf);
|
||||
JsonNode root = objectMapper.readTree(json);
|
||||
assertEquals(Set.of(tf), StrategyDslTimeframeNormalizer.resolveStartCandleTypes(root),
|
||||
"expected " + tf);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalize_alignsStaleStartToConditionTimeframe() throws Exception {
|
||||
String json = """
|
||||
{
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "1m",
|
||||
"candleTypes": ["1m", "15m"],
|
||||
"children": [{
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "CCI",
|
||||
"leftCandleType": "15m"
|
||||
}
|
||||
}]
|
||||
}
|
||||
""";
|
||||
JsonNode root = objectMapper.readTree(normalizer.normalizeJson(json, "test"));
|
||||
assertEquals(1, root.path("candleTypes").size());
|
||||
assertEquals("15m", root.path("candleTypes").get(0).asText());
|
||||
assertEquals("15m", root.path("candleType").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalize_preservesMultiCandleTypesOnSave() throws Exception {
|
||||
String json = """
|
||||
|
||||
Reference in New Issue
Block a user