일목균형표 수정
This commit is contained in:
@@ -107,6 +107,11 @@ public class GcAppSettings {
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
private String chartLegendOptionsJson;
|
||||
|
||||
/** 차트 pane 구분선 옵션 JSON (visible, color, width, lineStyle) */
|
||||
@Column(name = "chart_pane_separator_json", columnDefinition = "JSON")
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
private String chartPaneSeparatorJson;
|
||||
|
||||
/** 매매 시그널 발생 시 알림 팝업 표시 여부 (기본 true) */
|
||||
@Column(name = "trade_alert_popup", nullable = false)
|
||||
@Builder.Default
|
||||
|
||||
@@ -95,6 +95,7 @@ public class AppSettingsService {
|
||||
if (d.containsKey("chartLiveReceiveHighlight")) s.setChartLiveReceiveHighlight(
|
||||
Boolean.parseBoolean(d.get("chartLiveReceiveHighlight").toString()));
|
||||
if (d.containsKey("chartLegendOptions")) s.setChartLegendOptionsJson(toJson(d.get("chartLegendOptions")));
|
||||
if (d.containsKey("chartPaneSeparator")) s.setChartPaneSeparatorJson(toJson(d.get("chartPaneSeparator")));
|
||||
if (d.containsKey("tradeAlertPopup")) s.setTradeAlertPopup(
|
||||
Boolean.parseBoolean(d.get("tradeAlertPopup").toString()));
|
||||
if (d.containsKey("tradeAlertSoundEnabled")) s.setTradeAlertSoundEnabled(
|
||||
@@ -192,6 +193,7 @@ public class AppSettingsService {
|
||||
m.put("chartVolumeVisible", s.getChartVolumeVisible() != null ? s.getChartVolumeVisible() : true);
|
||||
m.put("chartLiveReceiveHighlight", s.getChartLiveReceiveHighlight() != null ? s.getChartLiveReceiveHighlight() : true);
|
||||
m.put("chartLegendOptions", parseJson(s.getChartLegendOptionsJson()));
|
||||
m.put("chartPaneSeparator", parseJson(s.getChartPaneSeparatorJson()));
|
||||
m.put("tradeAlertPopup", s.getTradeAlertPopup() != null ? s.getTradeAlertPopup() : true);
|
||||
m.put("tradeAlertSoundEnabled", s.getTradeAlertSoundEnabled() != null ? s.getTradeAlertSoundEnabled() : true);
|
||||
m.put("tradeAlertSound", s.getTradeAlertSound() != null ? s.getTradeAlertSound() : "bell");
|
||||
|
||||
@@ -588,13 +588,13 @@ public class IndicatorService {
|
||||
int conv = intP(p, "conversionPeriods", 9);
|
||||
int base = intP(p, "basePeriods", 26);
|
||||
int span2 = intP(p, "laggingSpan2Periods", 52);
|
||||
int disp = intP(p, "displacement", 26);
|
||||
int senkouDisp = intP(p, "senkouDisplacement", intP(p, "displacement", 26));
|
||||
int chikouDisp = intP(p, "chikouDisplacement", intP(p, "displacement", 26));
|
||||
IchimokuTenkanSenIndicator tenkan = new IchimokuTenkanSenIndicator(s, conv);
|
||||
IchimokuKijunSenIndicator kijun = new IchimokuKijunSenIndicator(s, base);
|
||||
IchimokuSenkouSpanAIndicator spanA = new IchimokuSenkouSpanAIndicator(s, tenkan, kijun, disp);
|
||||
// 선행스팬B displacement 도 spanA·chikou 와 동일한 disp 를 적용해야 한다
|
||||
IchimokuSenkouSpanBIndicator spanB = new IchimokuSenkouSpanBIndicator(s, span2, disp);
|
||||
IchimokuChikouSpanIndicator chikou = new IchimokuChikouSpanIndicator(s, disp);
|
||||
IchimokuSenkouSpanAIndicator spanA = new IchimokuSenkouSpanAIndicator(s, tenkan, kijun, senkouDisp);
|
||||
IchimokuSenkouSpanBIndicator spanB = new IchimokuSenkouSpanBIndicator(s, span2, senkouDisp);
|
||||
IchimokuChikouSpanIndicator chikou = new IchimokuChikouSpanIndicator(s, chikouDisp);
|
||||
return Map.of(
|
||||
"plot0", toPlot(s, tenkan),
|
||||
"plot1", toPlot(s, kijun),
|
||||
|
||||
@@ -58,6 +58,7 @@ public class LiveStrategyTimeframeService {
|
||||
if (candleType == null || candleType.isBlank()) return "1m";
|
||||
String c = candleType.trim().toLowerCase();
|
||||
if ("1d".equals(c)) return "1d";
|
||||
if ("1w".equals(c)) return "1w";
|
||||
if ("1h".equals(c)) return "1h";
|
||||
if ("4h".equals(c)) return "4h";
|
||||
if (ALLOWED.contains(c)) return c;
|
||||
@@ -69,7 +70,8 @@ public class LiveStrategyTimeframeService {
|
||||
case "1m", "3m", "5m", "10m", "15m", "30m" -> chartTf;
|
||||
case "1h", "4h" -> chartTf;
|
||||
case "1D" -> "1d";
|
||||
case "1W", "1M" -> "1d";
|
||||
case "1W" -> "1w";
|
||||
case "1M" -> "1d";
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
+15
-2
@@ -148,7 +148,7 @@ public class StrategyConditionTimeframeService {
|
||||
String type = node.path("type").asText("");
|
||||
|
||||
if ("TIMEFRAME".equals(type)) {
|
||||
out.add(LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m")));
|
||||
addTimeframeCandleTypes(node, out);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ public class StrategyConditionTimeframeService {
|
||||
if (children.isArray() && !children.isEmpty()) {
|
||||
if (allTimeframeChildren(children)) {
|
||||
for (JsonNode tf : children) {
|
||||
out.add(LiveStrategyTimeframeService.normalize(tf.path("candleType").asText("1m")));
|
||||
addTimeframeCandleTypes(tf, out);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -183,4 +183,17 @@ public class StrategyConditionTimeframeService {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** TIMEFRAME 노드 — candleTypes 배열 또는 단일 candleType */
|
||||
static void addTimeframeCandleTypes(JsonNode timeframeNode, Set<String> out) {
|
||||
JsonNode arr = timeframeNode.path("candleTypes");
|
||||
if (arr.isArray() && !arr.isEmpty()) {
|
||||
for (JsonNode t : arr) {
|
||||
String ct = LiveStrategyTimeframeService.normalize(t.asText(""));
|
||||
if (!ct.isBlank()) out.add(ct);
|
||||
}
|
||||
return;
|
||||
}
|
||||
out.add(LiveStrategyTimeframeService.normalize(timeframeNode.path("candleType").asText("1m")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ public class StrategyDslTimeframeNormalizer {
|
||||
if (root == null || root.isNull()) return root;
|
||||
|
||||
if ("TIMEFRAME".equals(root.path("type").asText(""))) {
|
||||
// START 다중 분봉(candleTypes) — 편집기 저장값 유지
|
||||
if (hasMultipleCandleTypes(root)) {
|
||||
return root;
|
||||
}
|
||||
String expected = inferPrimaryCandleType(root, strategyName);
|
||||
String current = LiveStrategyTimeframeService.normalize(root.path("candleType").asText("1m"));
|
||||
if (!expected.equals(current)) {
|
||||
@@ -145,7 +149,7 @@ public class StrategyDslTimeframeNormalizer {
|
||||
|
||||
String type = node.path("type").asText("");
|
||||
if ("TIMEFRAME".equals(type)) {
|
||||
out.add(LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m")));
|
||||
StrategyConditionTimeframeService.addTimeframeCandleTypes(node, out);
|
||||
}
|
||||
|
||||
if ("CONDITION".equals(type)) {
|
||||
@@ -193,6 +197,11 @@ public class StrategyDslTimeframeNormalizer {
|
||||
return "1m";
|
||||
}
|
||||
|
||||
private static boolean hasMultipleCandleTypes(JsonNode timeframeNode) {
|
||||
JsonNode arr = timeframeNode.path("candleTypes");
|
||||
return arr.isArray() && arr.size() > 1;
|
||||
}
|
||||
|
||||
private ObjectNode copyTimeframeWithCandleType(JsonNode tf, String candleType) {
|
||||
ObjectNode copy = tf.deepCopy();
|
||||
copy.put("candleType", LiveStrategyTimeframeService.normalize(candleType));
|
||||
|
||||
@@ -749,17 +749,24 @@ public class StrategyDslToTa4jAdapter {
|
||||
private IchimokuKijunSenIndicator ichimokuKijun(BarSeries s, Map<String, Object> p) {
|
||||
return new IchimokuKijunSenIndicator(s, intP(p, "basePeriods", 26));
|
||||
}
|
||||
private int ichimokuSenkouDisplacement(Map<String, Object> p) {
|
||||
return intP(p, "senkouDisplacement", intP(p, "displacement", 26));
|
||||
}
|
||||
|
||||
private int ichimokuChikouDisplacement(Map<String, Object> p) {
|
||||
return intP(p, "chikouDisplacement", intP(p, "displacement", 26));
|
||||
}
|
||||
|
||||
private IchimokuSenkouSpanAIndicator ichimokuSpanA(BarSeries s, Map<String, Object> p) {
|
||||
return new IchimokuSenkouSpanAIndicator(s, ichimokuTenkan(s, p), ichimokuKijun(s, p),
|
||||
intP(p, "displacement", 26));
|
||||
ichimokuSenkouDisplacement(p));
|
||||
}
|
||||
private IchimokuSenkouSpanBIndicator ichimokuSpanB(BarSeries s, Map<String, Object> p) {
|
||||
// IndicatorService 와 동일하게 displacement 파라미터 반영
|
||||
return new IchimokuSenkouSpanBIndicator(s, intP(p, "laggingSpan2Periods", 52),
|
||||
intP(p, "displacement", 26));
|
||||
ichimokuSenkouDisplacement(p));
|
||||
}
|
||||
private IchimokuChikouSpanIndicator ichimokuLagging(BarSeries s, Map<String, Object> p) {
|
||||
return new IchimokuChikouSpanIndicator(s, intP(p, "displacement", 26));
|
||||
return new IchimokuChikouSpanIndicator(s, ichimokuChikouDisplacement(p));
|
||||
}
|
||||
|
||||
/** 종가가 구름 위 */
|
||||
|
||||
@@ -82,9 +82,17 @@ public class StrategyTriggerBranchEvaluator {
|
||||
String type = node.path("type").asText("");
|
||||
|
||||
if ("TIMEFRAME".equals(type)) {
|
||||
String ct = LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m"));
|
||||
List<String> types = readTimeframeCandleTypes(node);
|
||||
JsonNode sub = firstChild(node);
|
||||
return new BranchScope("SINGLE", List.of(new BranchDef(ct, sub)));
|
||||
if (types.size() <= 1) {
|
||||
String ct = types.isEmpty() ? "1m" : types.get(0);
|
||||
return new BranchScope("SINGLE", List.of(new BranchDef(ct, sub)));
|
||||
}
|
||||
List<BranchDef> branches = new ArrayList<>();
|
||||
for (String ct : types) {
|
||||
branches.add(new BranchDef(ct, sub));
|
||||
}
|
||||
return new BranchScope("OR", branches);
|
||||
}
|
||||
|
||||
if ("AND".equals(type) || "OR".equals(type)) {
|
||||
@@ -92,9 +100,10 @@ public class StrategyTriggerBranchEvaluator {
|
||||
if (children.isArray() && !children.isEmpty() && allTimeframeChildren(children)) {
|
||||
List<BranchDef> branches = new ArrayList<>();
|
||||
for (JsonNode child : children) {
|
||||
String ct = LiveStrategyTimeframeService.normalize(
|
||||
child.path("candleType").asText("1m"));
|
||||
branches.add(new BranchDef(ct, firstChild(child)));
|
||||
JsonNode sub = firstChild(child);
|
||||
for (String ct : readTimeframeCandleTypes(child)) {
|
||||
branches.add(new BranchDef(ct, sub));
|
||||
}
|
||||
}
|
||||
return new BranchScope(type, branches);
|
||||
}
|
||||
@@ -102,9 +111,18 @@ public class StrategyTriggerBranchEvaluator {
|
||||
|
||||
JsonNode wrapped = findTimeframeWrapper(node);
|
||||
if (wrapped != null) {
|
||||
String ct = LiveStrategyTimeframeService.normalize(wrapped.path("candleType").asText("1m"));
|
||||
List<String> types = readTimeframeCandleTypes(wrapped);
|
||||
JsonNode sub = firstChild(wrapped);
|
||||
return new BranchScope("SINGLE", List.of(new BranchDef(ct, sub != null && !sub.isNull() ? sub : node)));
|
||||
JsonNode subtree = sub != null && !sub.isNull() ? sub : node;
|
||||
if (types.size() <= 1) {
|
||||
String ct = types.isEmpty() ? "1m" : types.get(0);
|
||||
return new BranchScope("SINGLE", List.of(new BranchDef(ct, subtree)));
|
||||
}
|
||||
List<BranchDef> branches = new ArrayList<>();
|
||||
for (String ct : types) {
|
||||
branches.add(new BranchDef(ct, subtree));
|
||||
}
|
||||
return new BranchScope("OR", branches);
|
||||
}
|
||||
|
||||
String inferred = StrategyDslTimeframeNormalizer.inferPrimaryCandleType(node);
|
||||
@@ -143,6 +161,21 @@ public class StrategyTriggerBranchEvaluator {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** START 다중 분봉 — 각 마감봉마다 동일 조건 트리를 독립 평가 */
|
||||
private static List<String> readTimeframeCandleTypes(JsonNode timeframeNode) {
|
||||
JsonNode arr = timeframeNode.path("candleTypes");
|
||||
if (arr.isArray() && !arr.isEmpty()) {
|
||||
List<String> list = new ArrayList<>();
|
||||
for (JsonNode t : arr) {
|
||||
String ct = LiveStrategyTimeframeService.normalize(t.asText(""));
|
||||
if (!ct.isBlank() && !list.contains(ct)) list.add(ct);
|
||||
}
|
||||
if (!list.isEmpty()) return list;
|
||||
}
|
||||
return List.of(LiveStrategyTimeframeService.normalize(
|
||||
timeframeNode.path("candleType").asText("1m")));
|
||||
}
|
||||
|
||||
private final class TriggerBranchRule implements Rule {
|
||||
private final BranchScope scope;
|
||||
private final String market;
|
||||
|
||||
@@ -25,7 +25,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
*
|
||||
* 구조: ConcurrentHashMap<market, Map<candleType, BarSeries>>
|
||||
* - market : "KRW-BTC", "KRW-ETH" 등 업비트 종목 코드
|
||||
* - candleType: "1m", "3m", "5m", "10m", "15m", "30m", "1h", "4h", "1d"
|
||||
* - candleType: "1m", "3m", "5m", "10m", "15m", "30m", "1h", "4h", "1w", "1d"
|
||||
*
|
||||
* 슬라이딩 윈도우: 모든 BarSeries 에 setMaximumBarCount(300) 적용.
|
||||
* 301번째 캔들이 들어오면 가장 오래된 캔들이 자동 소멸.
|
||||
@@ -56,6 +56,7 @@ public class Ta4jStorage {
|
||||
"30m", "minutes/30",
|
||||
"1h", "minutes/60",
|
||||
"4h", "minutes/240",
|
||||
"1w", "weeks",
|
||||
"1d", "days"
|
||||
);
|
||||
|
||||
@@ -237,6 +238,7 @@ public class Ta4jStorage {
|
||||
case "30m" -> Duration.ofMinutes(30);
|
||||
case "1h" -> Duration.ofHours(1);
|
||||
case "4h" -> Duration.ofHours(4);
|
||||
case "1w" -> Duration.ofDays(7);
|
||||
case "1d" -> Duration.ofDays(1);
|
||||
default -> Duration.ofMinutes(1);
|
||||
};
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ public class CandleGapBackfillService {
|
||||
@Value("${upbit.api.request-delay-ms:110}")
|
||||
private long requestDelayMs;
|
||||
|
||||
private static final String[] BACKFILL_TYPES = {"1m", "3m", "5m", "10m", "15m", "30m", "1h", "4h", "1d"};
|
||||
private static final String[] BACKFILL_TYPES = {"1m", "3m", "5m", "10m", "15m", "30m", "1h", "4h", "1w", "1d"};
|
||||
|
||||
private volatile long lastRunAtMs;
|
||||
private final AtomicInteger lastRunMarkets = new AtomicInteger();
|
||||
|
||||
@@ -32,9 +32,9 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
* 명세서 3.2 준수:
|
||||
* - 동일 분(minute) 내 틱 → series.addPrice(price, volume) 로 현재 Bar 갱신
|
||||
* - 분이 바뀌는 틱 → 이전 Bar 를 확정하고 series.addBar(newBar) 로 새 캔들 열기
|
||||
* - 1분봉 확정 시 상위 타임프레임(3m, 5m, 10m, 15m, 30m, 1h, 4h, 1d)으로 집계 전파
|
||||
* - 1분봉 확정 시 상위 타임프레임(3m, 5m, 10m, 15m, 30m, 1h, 4h, 1w, 1d)으로 집계 전파
|
||||
*
|
||||
* 지원 캔들 타입: 1m, 3m, 5m, 10m, 15m, 30m, 1h, 4h, 1d
|
||||
* 지원 캔들 타입: 1m, 3m, 5m, 10m, 15m, 30m, 1h, 4h, 1w, 1d
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@@ -50,8 +50,8 @@ public class BarBuilder {
|
||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||
|
||||
/** 상위 집계 타임프레임 (분 단위 집계 주기: 3, 5, 10, 15, 30, 60, 240, 1440) */
|
||||
private static final int[] UPPER_MINUTES = {3, 5, 10, 15, 30, 60, 240, 1440};
|
||||
private static final String[] UPPER_TYPES = {"3m", "5m", "10m", "15m", "30m", "1h", "4h", "1d"};
|
||||
private static final int[] UPPER_MINUTES = {3, 5, 10, 15, 30, 60, 240, 10080, 1440};
|
||||
private static final String[] UPPER_TYPES = {"3m", "5m", "10m", "15m", "30m", "1h", "4h", "1w", "1d"};
|
||||
|
||||
/** 진행 중인 1분봉 상태 (market → PartialBar) */
|
||||
private final ConcurrentHashMap<String, PartialBar> partialBars = new ConcurrentHashMap<>();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- ============================================================
|
||||
-- V42: gc_app_settings — 차트 pane 구분선 옵션 JSON
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE gc_app_settings
|
||||
ADD COLUMN chart_pane_separator_json JSON NULL
|
||||
COMMENT '차트 영역(캔들·거래량·보조지표 pane) 구분선: visible,color,width,lineStyle';
|
||||
+22
@@ -55,6 +55,28 @@ class StrategyConditionTimeframeServiceTest {
|
||||
assertFalse(service.usesTimeframe(1L, "1m"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectForStrategy_readsMultipleCandleTypesFromTimeframeNode() {
|
||||
GcStrategy multi = new GcStrategy();
|
||||
multi.setId(3L);
|
||||
multi.setBuyConditionJson("""
|
||||
{
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "1m",
|
||||
"candleTypes": ["1m", "3m", "5m"],
|
||||
"children": [{
|
||||
"type": "CONDITION",
|
||||
"condition": { "indicatorType": "RSI", "period": 14 }
|
||||
}]
|
||||
}
|
||||
""");
|
||||
when(strategyRepo.findById(3L)).thenReturn(Optional.of(multi));
|
||||
|
||||
Set<String> tfs = service.collectForStrategy(3L);
|
||||
assertEquals(Set.of("1m", "3m", "5m"), tfs);
|
||||
assertTrue(service.usesTimeframe(3L, "3m"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectForStrategy_legacyTreeDefaultsTo1m() {
|
||||
GcStrategy legacy = new GcStrategy();
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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.*;
|
||||
|
||||
class StrategyDslTimeframeNormalizerTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final StrategyDslTimeframeNormalizer normalizer = new StrategyDslTimeframeNormalizer(objectMapper);
|
||||
|
||||
@Test
|
||||
void normalize_preservesMultiCandleTypesOnSave() throws Exception {
|
||||
String json = """
|
||||
{
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "1m",
|
||||
"candleTypes": ["1m", "3m", "5m"],
|
||||
"children": [{
|
||||
"type": "CONDITION",
|
||||
"condition": { "indicatorType": "RSI", "period": 14 }
|
||||
}]
|
||||
}
|
||||
""";
|
||||
String normalized = normalizer.normalizeJson(json, "5분봉 테스트 전략");
|
||||
JsonNode root = objectMapper.readTree(normalized);
|
||||
assertTrue(root.path("candleTypes").isArray());
|
||||
assertEquals(3, root.path("candleTypes").size());
|
||||
assertEquals("1m", root.path("candleTypes").get(0).asText());
|
||||
assertEquals("3m", root.path("candleTypes").get(1).asText());
|
||||
assertEquals("5m", root.path("candleTypes").get(2).asText());
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.goldenchart.entity.GcStrategy;
|
||||
import com.goldenchart.repository.GcStrategyRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* START candleTypes → BarBuilder 게이트(usesTimeframe) → TriggerBranch 평가 연결 검증.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class StrategyEvaluationTimeframeIntegrationTest {
|
||||
|
||||
@Mock
|
||||
private GcStrategyRepository strategyRepo;
|
||||
|
||||
@Spy
|
||||
private ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Mock
|
||||
private StrategyDslTimeframeNormalizer dslNormalizer;
|
||||
|
||||
@Mock
|
||||
private LiveStrategyEvaluator liveStrategyEvaluator;
|
||||
|
||||
private StrategyConditionTimeframeService timeframeService;
|
||||
|
||||
private StrategyTriggerBranchEvaluator triggerEvaluator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
timeframeService = new StrategyConditionTimeframeService(
|
||||
strategyRepo, objectMapper, dslNormalizer, liveStrategyEvaluator);
|
||||
triggerEvaluator = new StrategyTriggerBranchEvaluator(
|
||||
new StrategyDslToTa4jAdapter(), objectMapper, new StrategyBranchStateCache());
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkedTimeframes_gateBarCloseAndTriggerScope() throws Exception {
|
||||
String buyJson = """
|
||||
{
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "1m",
|
||||
"candleTypes": ["1m", "3m", "5m"],
|
||||
"children": [{
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "RSI", "conditionType": "GT",
|
||||
"leftField": "RSI_VALUE", "rightField": "K_0", "period": 14
|
||||
}
|
||||
}]
|
||||
}
|
||||
""";
|
||||
GcStrategy strategy = new GcStrategy();
|
||||
strategy.setId(100L);
|
||||
strategy.setName("multi tf test");
|
||||
strategy.setBuyConditionJson(buyJson);
|
||||
when(strategyRepo.findById(100L)).thenReturn(Optional.of(strategy));
|
||||
when(dslNormalizer.normalizeJson(buyJson, strategy.getName())).thenReturn(buyJson);
|
||||
|
||||
Set<String> collected = timeframeService.collectForStrategy(100L);
|
||||
assertEquals(Set.of("1m", "3m", "5m"), collected);
|
||||
|
||||
assertTrue(timeframeService.usesTimeframe(100L, "1m"));
|
||||
assertTrue(timeframeService.usesTimeframe(100L, "3m"));
|
||||
assertTrue(timeframeService.usesTimeframe(100L, "5m"));
|
||||
assertFalse(timeframeService.usesTimeframe(100L, "15m"));
|
||||
|
||||
var scope = triggerEvaluator.parseScope(buyJson);
|
||||
assertEquals("OR", scope.combineOp());
|
||||
assertEquals(3, scope.branches().size());
|
||||
assertTrue(scope.branches().stream().anyMatch(b -> "3m".equals(b.candleType())));
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,52 @@ class StrategyTriggerBranchEvaluatorTest {
|
||||
assertTrue(rule1m.isSatisfied(idx1m, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiCandleTypes_sameSubtree_onlyEvaluatesTriggerBranch() throws Exception {
|
||||
String json = """
|
||||
{
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "1m",
|
||||
"candleTypes": ["1m", "3m", "5m"],
|
||||
"children": [{
|
||||
"type": "CONDITION", "condition": {
|
||||
"indicatorType": "RSI", "conditionType": "GT",
|
||||
"leftField": "RSI_VALUE", "rightField": "K_0", "period": 14
|
||||
}
|
||||
}]
|
||||
}
|
||||
""";
|
||||
|
||||
var scope = evaluator.parseScope(json);
|
||||
assertEquals("OR", scope.combineOp());
|
||||
assertEquals(3, scope.branches().size());
|
||||
assertEquals("1m", scope.branches().get(0).candleType());
|
||||
assertEquals("3m", scope.branches().get(1).candleType());
|
||||
assertEquals("5m", scope.branches().get(2).candleType());
|
||||
|
||||
Rule on1m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy",
|
||||
"1m", Map.of(), storage);
|
||||
BarSeries s1m = storage.getOrCreate(MARKET, "1m");
|
||||
on1m.isSatisfied(s1m.getEndIndex(), null);
|
||||
assertNotNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "1m"));
|
||||
assertNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "3m"),
|
||||
"1m 마감 트리거 시 3m 분기는 평가·캐시되지 않아야 함");
|
||||
assertNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "5m"));
|
||||
|
||||
branchCache.invalidateStrategy(STRATEGY_ID);
|
||||
Rule on3m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy",
|
||||
"3m", Map.of(), storage);
|
||||
BarSeries s3m = storage.getOrCreate(MARKET, "3m");
|
||||
on3m.isSatisfied(s3m.getEndIndex(), null);
|
||||
assertNotNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "3m"));
|
||||
assertNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "1m"));
|
||||
|
||||
Rule on5mWrongTrigger = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy",
|
||||
"1m", Map.of(), storage);
|
||||
assertFalse(on5mWrongTrigger.isSatisfied(s3m.getEndIndex(), null),
|
||||
"3m 인덱스로 1m 트리거 Rule 평가 시 false");
|
||||
}
|
||||
|
||||
@Test
|
||||
void single3mScope_onlyTriggersOn3mBranch() throws Exception {
|
||||
String json = """
|
||||
|
||||
Reference in New Issue
Block a user