매매 시그널 알림 화면 수정
This commit is contained in:
@@ -67,6 +67,11 @@ public class GcAppSettings {
|
||||
@Builder.Default
|
||||
private String displayTimezone = "Asia/Seoul";
|
||||
|
||||
/** 차트 하단 시간축 표시 포맷 (예: yyyy-MM-dd HH:mm) */
|
||||
@Column(name = "chart_time_format", length = 64, nullable = false)
|
||||
@Builder.Default
|
||||
private String chartTimeFormat = "MM-dd HH:mm";
|
||||
|
||||
/** 캔들 색상 JSON (frontend MainChartStyle 구조) */
|
||||
@Column(name = "main_chart_style_json", columnDefinition = "JSON")
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
|
||||
@@ -82,6 +82,7 @@ public class AppSettingsService {
|
||||
Boolean.parseBoolean(d.get("defaultLogScale").toString()));
|
||||
if (d.containsKey("defaultLayoutId")) s.setDefaultLayoutId((String) d.get("defaultLayoutId"));
|
||||
if (d.containsKey("displayTimezone")) s.setDisplayTimezone((String) d.get("displayTimezone"));
|
||||
if (d.containsKey("chartTimeFormat")) s.setChartTimeFormat(normalizeChartTimeFormat((String) d.get("chartTimeFormat")));
|
||||
if (d.containsKey("mainChartStyle")) s.setMainChartStyleJson(toJson(d.get("mainChartStyle")));
|
||||
if (d.containsKey("syncOptions")) s.setSyncOptionsJson(toJson(d.get("syncOptions")));
|
||||
if (d.containsKey("btAutoPopup")) s.setBtAutoPopup(
|
||||
@@ -188,6 +189,7 @@ public class AppSettingsService {
|
||||
m.put("defaultLogScale", s.getDefaultLogScale() != null ? s.getDefaultLogScale() : false);
|
||||
m.put("defaultLayoutId", s.getDefaultLayoutId() != null ? s.getDefaultLayoutId() : "1");
|
||||
m.put("displayTimezone", s.getDisplayTimezone() != null ? s.getDisplayTimezone() : "Asia/Seoul");
|
||||
m.put("chartTimeFormat", s.getChartTimeFormat() != null ? s.getChartTimeFormat() : "MM-dd HH:mm");
|
||||
m.put("mainChartStyle", parseJson(s.getMainChartStyleJson()));
|
||||
m.put("syncOptions", parseJson(s.getSyncOptionsJson()));
|
||||
m.put("btAutoPopup", s.getBtAutoPopup() != null ? s.getBtAutoPopup() : true);
|
||||
@@ -282,4 +284,11 @@ public class AppSettingsService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeChartTimeFormat(String raw) {
|
||||
if (raw == null) return "MM-dd HH:mm";
|
||||
String t = raw.trim();
|
||||
if (t.isEmpty() || !t.matches(".*[yMdHms].*")) return "MM-dd HH:mm";
|
||||
return t.length() > 64 ? t.substring(0, 64) : t;
|
||||
}
|
||||
}
|
||||
|
||||
+33
-3
@@ -69,7 +69,7 @@ public class StrategyConditionTimeframeService {
|
||||
|
||||
Set<String> fromFlowLayout = collectFromFlowLayoutJson(strategy.getFlowLayoutJson());
|
||||
if (!fromFlowLayout.isEmpty()) {
|
||||
return fromFlowLayout;
|
||||
return sanitizeEvaluationTimeframes(fromFlowLayout, strategy);
|
||||
}
|
||||
|
||||
Set<String> buyOut = new LinkedHashSet<>();
|
||||
@@ -79,10 +79,10 @@ public class StrategyConditionTimeframeService {
|
||||
|
||||
// 매수 START 분봉 우선 — 매도 쪽 레거시 1m TIMEFRAME 이 합집합에 섞이지 않도록
|
||||
if (buyScoped && !buyOut.isEmpty()) {
|
||||
return buyOut;
|
||||
return sanitizeEvaluationTimeframes(buyOut, strategy);
|
||||
}
|
||||
if (sellScoped && !sellOut.isEmpty()) {
|
||||
return sellOut;
|
||||
return sanitizeEvaluationTimeframes(sellOut, strategy);
|
||||
}
|
||||
|
||||
Set<String> out = new LinkedHashSet<>();
|
||||
@@ -99,9 +99,39 @@ public class StrategyConditionTimeframeService {
|
||||
String fromName = StrategyDslTimeframeNormalizer.inferFromStrategyName(strategy.getName());
|
||||
return Set.of(fromName != null ? fromName : "1m");
|
||||
}
|
||||
return sanitizeEvaluationTimeframes(out, strategy);
|
||||
}
|
||||
|
||||
/**
|
||||
* START/flow 에만 남은 stale 1m 제거 — DSL 조건에 명시된 분봉 기준.
|
||||
*/
|
||||
private Set<String> sanitizeEvaluationTimeframes(Set<String> scope, GcStrategy strategy) {
|
||||
Set<String> explicit = collectExplicitFromStrategy(strategy);
|
||||
if (explicit.isEmpty()) return scope;
|
||||
if (!explicit.contains("1m") && scope.contains("1m")) {
|
||||
Set<String> filtered = new LinkedHashSet<>(scope);
|
||||
filtered.remove("1m");
|
||||
return filtered.isEmpty() ? scope : filtered;
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
private Set<String> collectExplicitFromStrategy(GcStrategy strategy) {
|
||||
Set<String> out = new LinkedHashSet<>();
|
||||
collectExplicitFromJson(strategy.getBuyConditionJson(), out);
|
||||
collectExplicitFromJson(strategy.getSellConditionJson(), out);
|
||||
return out;
|
||||
}
|
||||
|
||||
private void collectExplicitFromJson(String json, Set<String> out) {
|
||||
if (json == null || json.isBlank()) return;
|
||||
try {
|
||||
out.addAll(StrategyDslTimeframeNormalizer.collectExplicitCandleTypes(objectMapper.readTree(json)));
|
||||
} catch (Exception e) {
|
||||
log.warn("[StrategyTimeframes] explicit JSON 파싱 실패: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** TIMEFRAME 누락·잘못된 1m 래핑을 전략명·DSL 기준으로 DB 보정 */
|
||||
private void ensureDslRepaired(GcStrategy strategy) {
|
||||
boolean changed = false;
|
||||
|
||||
@@ -282,6 +282,13 @@ public class StrategyDslTimeframeNormalizer {
|
||||
|
||||
if (fromConditions.size() == 1) {
|
||||
String required = fromConditions.iterator().next();
|
||||
// START에 5m 등 명시 분봉이 있으면 조건 노드 기본값 1m 보다 우선 (편집기 START 변경 직후)
|
||||
if ("1m".equals(required) && hasExplicitNon1mStart(fromStart)) {
|
||||
if (fromStart.size() == 1) {
|
||||
return fromStart;
|
||||
}
|
||||
return dropStale1mFromStart(fromStart, fromConditions);
|
||||
}
|
||||
// 레거시 [1m, X] — 조건이 X 단일 분봉이면 X만
|
||||
if (fromStart.size() == 2 && fromStart.contains("1m") && fromStart.contains(required)) {
|
||||
return Set.of(required);
|
||||
@@ -302,11 +309,21 @@ public class StrategyDslTimeframeNormalizer {
|
||||
|
||||
// 조건에 복수 분봉 명시 — START가 상위집합이면 START 유지 (다중 OR)
|
||||
if (fromStart.containsAll(fromConditions) && fromStart.size() >= fromConditions.size()) {
|
||||
return fromStart;
|
||||
return dropStale1mFromStart(fromStart, fromConditions);
|
||||
}
|
||||
return fromConditions;
|
||||
}
|
||||
|
||||
/** START 에만 남은 stale 1m 제거 — 조건에 1m 이 없으면 평가·알림 분봉에서 제외 */
|
||||
private static Set<String> dropStale1mFromStart(Set<String> fromStart, Set<String> fromConditions) {
|
||||
if (!fromStart.contains("1m") || fromConditions.contains("1m")) {
|
||||
return fromStart;
|
||||
}
|
||||
Set<String> filtered = new LinkedHashSet<>(fromStart);
|
||||
filtered.remove("1m");
|
||||
return filtered.isEmpty() ? fromStart : filtered;
|
||||
}
|
||||
|
||||
private ObjectNode writeResolvedTimeframe(JsonNode root, String strategyName) {
|
||||
Set<String> resolved = resolveStartCandleTypes(root);
|
||||
if (resolved.isEmpty()) {
|
||||
@@ -333,6 +350,14 @@ public class StrategyDslTimeframeNormalizer {
|
||||
return fromStart.size() == 1 && fromStart.contains("1m");
|
||||
}
|
||||
|
||||
/** START TIMEFRAME에 1m 이 아닌 분봉이 명시되어 있는지 */
|
||||
private static boolean hasExplicitNon1mStart(Set<String> fromStart) {
|
||||
for (String ct : fromStart) {
|
||||
if (!"1m".equals(ct)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private ObjectNode reconcileStartCandleTypes(JsonNode tf) {
|
||||
return reconcileStartCandleTypesStatic(tf);
|
||||
}
|
||||
@@ -355,8 +380,6 @@ public class StrategyDslTimeframeNormalizer {
|
||||
|
||||
long non1mCount = normalized.stream().filter(ct -> !"1m".equals(ct)).count();
|
||||
if (non1mCount == 0) return copy;
|
||||
// [1m, 3m, 5m] 등 1m+복수 상위봉 OR — 1m 의도적 선택으로 간주
|
||||
if (non1mCount >= 2) return copy;
|
||||
|
||||
ArrayNode newArr = JsonNodeFactory.instance.arrayNode();
|
||||
for (String ct : normalized) {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE gc_app_settings
|
||||
ADD COLUMN chart_time_format VARCHAR(64) NOT NULL DEFAULT 'MM-dd HH:mm' AFTER display_timezone;
|
||||
+42
-1
@@ -9,6 +9,8 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -18,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class StrategyConditionTimeframeServiceTest {
|
||||
|
||||
@Mock
|
||||
@@ -109,7 +112,11 @@ class StrategyConditionTimeframeServiceTest {
|
||||
"candleTypes": ["1m", "3m", "5m"],
|
||||
"children": [{
|
||||
"type": "CONDITION",
|
||||
"condition": { "indicatorType": "RSI", "period": 14 }
|
||||
"condition": {
|
||||
"indicatorType": "RSI",
|
||||
"period": 14,
|
||||
"leftCandleType": "1m"
|
||||
}
|
||||
}]
|
||||
}
|
||||
""");
|
||||
@@ -235,6 +242,40 @@ class StrategyConditionTimeframeServiceTest {
|
||||
assertFalse(service.usesTimeframe(6L, "1m"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectForStrategy_cciMultiTfDropsStale1mFromStartArray() {
|
||||
GcStrategy strategy = new GcStrategy();
|
||||
strategy.setId(7L);
|
||||
strategy.setName("CCI | 3분 5분 10분 15분 1시간");
|
||||
strategy.setBuyConditionJson("""
|
||||
{
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "1m",
|
||||
"candleTypes": ["1m", "3m", "5m", "10m", "15m", "1h"],
|
||||
"children": [{
|
||||
"type": "AND",
|
||||
"children": [
|
||||
{ "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "3m" } },
|
||||
{ "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "5m" } },
|
||||
{ "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "10m" } },
|
||||
{ "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "15m" } },
|
||||
{ "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "1h" } }
|
||||
]
|
||||
}]
|
||||
}
|
||||
""");
|
||||
when(strategyRepo.findById(7L)).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 -> inv.getArgument(0));
|
||||
|
||||
Set<String> tfs = service.collectForStrategy(7L);
|
||||
assertEquals(Set.of("3m", "5m", "10m", "15m", "1h"), tfs);
|
||||
assertFalse(service.usesTimeframe(7L, "1m"));
|
||||
assertTrue(service.usesTimeframe(7L, "3m"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectForStrategy_legacyTreeDefaultsTo1m() {
|
||||
GcStrategy legacy = new GcStrategy();
|
||||
|
||||
+55
-1
@@ -76,6 +76,31 @@ class StrategyDslTimeframeNormalizerTest {
|
||||
assertEquals("5m", root.path("candleType").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveStartCandleTypes_explicitStart5mBeatsConditionDefault1m() throws Exception {
|
||||
String json = """
|
||||
{
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "5m",
|
||||
"candleTypes": ["5m"],
|
||||
"children": [{
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "CCI",
|
||||
"leftCandleType": "1m",
|
||||
"rightCandleType": "1m"
|
||||
}
|
||||
}]
|
||||
}
|
||||
""";
|
||||
JsonNode root = objectMapper.readTree(json);
|
||||
assertEquals(Set.of("5m"), StrategyDslTimeframeNormalizer.resolveStartCandleTypes(root));
|
||||
String normalized = normalizer.normalizeJson(json, "cci_5분봉");
|
||||
JsonNode out = objectMapper.readTree(normalized);
|
||||
assertEquals("5m", out.path("candleType").asText());
|
||||
assertEquals("5m", out.path("candleTypes").get(0).asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveStartCandleTypes_meaningfulOverridesStaleStart1m() throws Exception {
|
||||
String json = """
|
||||
@@ -161,6 +186,31 @@ class StrategyDslTimeframeNormalizerTest {
|
||||
assertEquals("15m", root.path("candleType").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveStartCandleTypes_dropsStale1mWhenConditionsUseMultipleUpperTfs() throws Exception {
|
||||
String json = """
|
||||
{
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "1m",
|
||||
"candleTypes": ["1m", "3m", "5m", "10m", "15m", "1h"],
|
||||
"children": [{
|
||||
"type": "AND",
|
||||
"children": [
|
||||
{ "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "3m" } },
|
||||
{ "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "5m" } },
|
||||
{ "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "10m" } },
|
||||
{ "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "15m" } },
|
||||
{ "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "1h" } }
|
||||
]
|
||||
}]
|
||||
}
|
||||
""";
|
||||
JsonNode root = objectMapper.readTree(json);
|
||||
assertEquals(
|
||||
Set.of("3m", "5m", "10m", "15m", "1h"),
|
||||
StrategyDslTimeframeNormalizer.resolveStartCandleTypes(root));
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalize_preservesMultiCandleTypesOnSave() throws Exception {
|
||||
String json = """
|
||||
@@ -170,7 +220,11 @@ class StrategyDslTimeframeNormalizerTest {
|
||||
"candleTypes": ["1m", "3m", "5m"],
|
||||
"children": [{
|
||||
"type": "CONDITION",
|
||||
"condition": { "indicatorType": "RSI", "period": 14 }
|
||||
"condition": {
|
||||
"indicatorType": "RSI",
|
||||
"period": 14,
|
||||
"leftCandleType": "1m"
|
||||
}
|
||||
}]
|
||||
}
|
||||
""";
|
||||
|
||||
Reference in New Issue
Block a user