전략조건 상세설명 기능 추가
This commit is contained in:
@@ -1,13 +1,11 @@
|
||||
package com.goldenchart.config;
|
||||
|
||||
import com.goldenchart.entity.GcAppSettings;
|
||||
import com.goldenchart.entity.GcLiveStrategySettings;
|
||||
import com.goldenchart.entity.GcWatchlist;
|
||||
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
||||
import com.goldenchart.repository.GcWatchlistRepository;
|
||||
import com.goldenchart.service.AppSettingsService;
|
||||
import com.goldenchart.service.LiveStrategyEvaluator;
|
||||
import com.goldenchart.service.LiveStrategyTimeframeService;
|
||||
import com.goldenchart.service.StrategyConditionTimeframeService;
|
||||
import com.goldenchart.websocket.DynamicSubscriptionManager;
|
||||
import com.goldenchart.websocket.UpbitWebSocketClient;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -27,9 +25,9 @@ public class LiveStrategyStartupRunner implements ApplicationRunner {
|
||||
|
||||
private final AppSettingsService appSettingsService;
|
||||
private final GcWatchlistRepository watchlistRepo;
|
||||
private final GcLiveStrategySettingsRepository liveSettingsRepo;
|
||||
private final DynamicSubscriptionManager subscriptionManager;
|
||||
private final LiveStrategyEvaluator liveStrategyEvaluator;
|
||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||
private final UpbitWebSocketClient upbitWebSocketClient;
|
||||
|
||||
@Override
|
||||
@@ -44,11 +42,7 @@ public class LiveStrategyStartupRunner implements ApplicationRunner {
|
||||
if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) || app.getLiveStrategyId() == null) {
|
||||
continue;
|
||||
}
|
||||
String ct = resolvePinnedCandleType(w);
|
||||
subscriptionManager.ensureMarketPinned(w.getSymbol(), "1m");
|
||||
if (!"1m".equals(ct)) {
|
||||
subscriptionManager.ensureMarketPinned(w.getSymbol(), ct);
|
||||
}
|
||||
pinStrategyTimeframes(w.getSymbol(), app.getLiveStrategyId());
|
||||
liveStrategyEvaluator.invalidateCache(w.getSymbol());
|
||||
pinned++;
|
||||
}
|
||||
@@ -59,19 +53,12 @@ public class LiveStrategyStartupRunner implements ApplicationRunner {
|
||||
upbitWebSocketClient.resubscribeAll();
|
||||
}
|
||||
|
||||
private String resolvePinnedCandleType(GcWatchlist w) {
|
||||
if (w.getUserId() != null) {
|
||||
return liveSettingsRepo.findByUserIdAndMarket(w.getUserId(), w.getSymbol())
|
||||
.map(GcLiveStrategySettings::getCandleType)
|
||||
.map(LiveStrategyTimeframeService::normalize)
|
||||
.orElse("1m");
|
||||
private void pinStrategyTimeframes(String market, Long strategyId) {
|
||||
subscriptionManager.ensureMarketPinned(market, "1m");
|
||||
for (String ct : conditionTimeframes.collectForStrategy(strategyId)) {
|
||||
if (!"1m".equals(ct)) {
|
||||
subscriptionManager.ensureMarketPinned(market, ct);
|
||||
}
|
||||
}
|
||||
if (w.getDeviceId() != null) {
|
||||
return liveSettingsRepo.findByDeviceIdAndMarket(w.getDeviceId(), w.getSymbol())
|
||||
.map(GcLiveStrategySettings::getCandleType)
|
||||
.map(LiveStrategyTimeframeService::normalize)
|
||||
.orElse("1m");
|
||||
}
|
||||
return "1m";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.goldenchart.controller;
|
||||
|
||||
import com.goldenchart.dto.StrategyDto;
|
||||
import com.goldenchart.service.StrategyConditionTimeframeService;
|
||||
import com.goldenchart.service.StrategyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -23,6 +24,7 @@ import java.util.List;
|
||||
public class StrategyController {
|
||||
|
||||
private final StrategyService service;
|
||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||
|
||||
/** 전략 목록 조회 */
|
||||
@GetMapping
|
||||
@@ -49,6 +51,12 @@ public class StrategyController {
|
||||
return ResponseEntity.ok(service.save(dto, parseUserId(userIdHeader), deviceId));
|
||||
}
|
||||
|
||||
/** 전략 DSL에 포함된 평가 시간봉 목록 */
|
||||
@GetMapping("/{id}/timeframes")
|
||||
public ResponseEntity<List<String>> timeframes(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(conditionTimeframes.collectForStrategyList(id));
|
||||
}
|
||||
|
||||
/** 전략 삭제 */
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||
|
||||
@@ -6,6 +6,8 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 실시간 전략 체크 설정 DTO.
|
||||
* PUT /api/strategy/settings (요청/응답 공용)
|
||||
@@ -29,10 +31,16 @@ public class LiveStrategySettingsDto {
|
||||
/** "CANDLE_CLOSE" | "REALTIME_TICK" */
|
||||
private String executionType;
|
||||
|
||||
/** 전략 평가 분봉: 1m, 3m, 5m, 15m, 30m, 1h, 4h, 1d */
|
||||
/**
|
||||
* @deprecated 전략 DSL의 START/TIMEFRAME에서 시간봉을 자동 추출합니다. 응답 전용 필드는 {@link #strategyCandleTypes}를 사용하세요.
|
||||
*/
|
||||
@Deprecated
|
||||
@Builder.Default
|
||||
private String candleType = "1m";
|
||||
|
||||
/** 전략 조건 DSL에 포함된 평가 시간봉 목록 (응답 전용) */
|
||||
private List<String> strategyCandleTypes;
|
||||
|
||||
/** 매도 시그널 포지션 종속성 모드: "LONG_ONLY" | "SIGNAL_ONLY" */
|
||||
@Builder.Default
|
||||
private String positionMode = "LONG_ONLY";
|
||||
|
||||
@@ -55,6 +55,7 @@ public class LiveStrategyEvaluator {
|
||||
private final IndicatorSettingsService indicatorSettingsService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final StrategySignalDeterminer determiner;
|
||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||
|
||||
/**
|
||||
* Strategy 캐시: "market:candleType:strategyId" → Strategy
|
||||
@@ -103,8 +104,7 @@ public class LiveStrategyEvaluator {
|
||||
if (!Boolean.TRUE.equals(s.getIsLiveCheck())) continue;
|
||||
if (!"CANDLE_CLOSE".equals(s.getExecutionType())) continue;
|
||||
if (s.getStrategyId() == null) continue;
|
||||
if (!candleType.equals(LiveStrategyTimeframeService.normalize(
|
||||
s.getCandleType() != null ? s.getCandleType() : "1m"))) continue;
|
||||
if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) continue;
|
||||
|
||||
String result = evaluate(market, candleType, s.getStrategyId(),
|
||||
s.getPositionMode(), maturedIndex,
|
||||
@@ -155,8 +155,7 @@ public class LiveStrategyEvaluator {
|
||||
if (!Boolean.TRUE.equals(s.getIsLiveCheck())) continue;
|
||||
if (!"REALTIME_TICK".equals(s.getExecutionType())) continue;
|
||||
if (s.getStrategyId() == null) continue;
|
||||
if (!candleType.equals(LiveStrategyTimeframeService.normalize(
|
||||
s.getCandleType() != null ? s.getCandleType() : "1m"))) continue;
|
||||
if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) continue;
|
||||
|
||||
// ★ Ta4j CachedIndicator 캐시 무효화:
|
||||
// updateLastBar 로 provisional 봉 가격이 바뀌어도 CCI 등 CachedIndicator 는
|
||||
@@ -202,8 +201,7 @@ public class LiveStrategyEvaluator {
|
||||
if (!Boolean.TRUE.equals(s.getIsLiveCheck())) continue;
|
||||
if (!"REALTIME_TICK".equals(s.getExecutionType())) continue;
|
||||
if (s.getStrategyId() == null) continue;
|
||||
if (!candleType.equals(LiveStrategyTimeframeService.normalize(
|
||||
s.getCandleType() != null ? s.getCandleType() : "1m"))) continue;
|
||||
if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) continue;
|
||||
|
||||
// CachedIndicator 캐시 무효화 — 확정봉의 최종 close 로 재계산
|
||||
String cacheKey = market + ":" + candleType + ":" + s.getStrategyId();
|
||||
|
||||
@@ -30,7 +30,7 @@ public class LiveStrategySettingsService {
|
||||
private final AppSettingsService appSettingsService;
|
||||
private final DynamicSubscriptionManager subscriptionManager;
|
||||
private final LiveStrategyEvaluator liveStrategyEvaluator;
|
||||
private final LiveStrategyTimeframeService timeframeService;
|
||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||
|
||||
// ── 조회 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -42,7 +42,9 @@ public class LiveStrategySettingsService {
|
||||
? toDto(entity.get())
|
||||
: defaultDtoFromApp(market, app);
|
||||
// 실시간 체크 마스터 스위치·전략 ID는 앱 전역 설정(gc_app_settings) 기준
|
||||
return mergeGlobalTemplate(dto, app, market);
|
||||
dto = mergeGlobalTemplate(dto, app, market);
|
||||
enrichStrategyTimeframes(dto);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -52,21 +54,17 @@ public class LiveStrategySettingsService {
|
||||
return List.of();
|
||||
}
|
||||
List<String> watchlist = listWatchlistSymbols(userId, deviceId);
|
||||
Long strategyId = app.getLiveStrategyId();
|
||||
List<String> strategyCandleTypes = conditionTimeframes.collectForStrategyList(strategyId);
|
||||
return watchlist.stream()
|
||||
.map(m -> {
|
||||
Optional<GcLiveStrategySettings> row = findEntity(userId, deviceId, m);
|
||||
String ct = row.map(GcLiveStrategySettings::getCandleType)
|
||||
.map(LiveStrategyTimeframeService::normalize)
|
||||
.orElseGet(() -> timeframeService.resolveCandleType(m, userId, deviceId));
|
||||
return LiveStrategySettingsDto.builder()
|
||||
.map(m -> LiveStrategySettingsDto.builder()
|
||||
.market(m)
|
||||
.strategyId(app.getLiveStrategyId())
|
||||
.strategyId(strategyId)
|
||||
.liveCheck(true)
|
||||
.executionType(app.getLiveExecutionType())
|
||||
.positionMode(app.getLivePositionMode())
|
||||
.candleType(ct)
|
||||
.build();
|
||||
})
|
||||
.strategyCandleTypes(strategyCandleTypes)
|
||||
.build())
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -77,18 +75,12 @@ public class LiveStrategySettingsService {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
LiveStrategySettingsDto template = findEntity(userId, deviceId, market)
|
||||
.map(e -> mergeGlobalTemplate(toDto(e), app, market))
|
||||
.orElseGet(() -> {
|
||||
LiveStrategySettingsDto dto = defaultDtoFromApp(market, app);
|
||||
String inherited = resolveSharedCandleType(userId, deviceId);
|
||||
if (inherited != null) {
|
||||
dto.setCandleType(inherited);
|
||||
}
|
||||
return mergeGlobalTemplate(dto, app, market);
|
||||
});
|
||||
.orElseGet(() -> mergeGlobalTemplate(defaultDtoFromApp(market, app), app, market));
|
||||
template.setLiveCheck(true);
|
||||
enrichStrategyTimeframes(template);
|
||||
upsertEntity(userId, deviceId, template);
|
||||
pinIfReady(market, template);
|
||||
log.debug("[LiveStrategy] watchlist add → enabled {} candleType={}", market, template.getCandleType());
|
||||
log.debug("[LiveStrategy] watchlist add → enabled {}", market);
|
||||
}
|
||||
|
||||
/** 관심종목 해제 시 — 해당 종목 전략 체크 비활성화 */
|
||||
@@ -121,7 +113,6 @@ public class LiveStrategySettingsService {
|
||||
.liveCheck(true)
|
||||
.executionType(template.getExecutionType())
|
||||
.positionMode(template.getPositionMode())
|
||||
.candleType(template.getCandleType() != null ? template.getCandleType() : "1m")
|
||||
.build();
|
||||
results.add(upsertEntity(userId, deviceId, one));
|
||||
}
|
||||
@@ -148,9 +139,7 @@ public class LiveStrategySettingsService {
|
||||
if (dto.getPositionMode() != null) {
|
||||
out.setPositionMode(dto.getPositionMode());
|
||||
}
|
||||
if (dto.getCandleType() != null) {
|
||||
out.setCandleType(LiveStrategyTimeframeService.normalize(dto.getCandleType()));
|
||||
}
|
||||
enrichStrategyTimeframes(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -170,9 +159,6 @@ public class LiveStrategySettingsService {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
boolean active = Boolean.TRUE.equals(app.getLiveStrategyCheck())
|
||||
&& app.getLiveStrategyId() != null;
|
||||
String candleType = template != null && template.getCandleType() != null
|
||||
? LiveStrategyTimeframeService.normalize(template.getCandleType())
|
||||
: resolveSharedCandleType(userId, deviceId);
|
||||
for (String symbol : listWatchlistSymbols(userId, deviceId)) {
|
||||
LiveStrategySettingsDto row = findEntity(userId, deviceId, symbol)
|
||||
.map(e -> mergeGlobalTemplate(toDto(e), app, symbol))
|
||||
@@ -183,9 +169,7 @@ public class LiveStrategySettingsService {
|
||||
? app.getLiveExecutionType() : row.getExecutionType());
|
||||
row.setPositionMode(app.getLivePositionMode() != null
|
||||
? app.getLivePositionMode() : row.getPositionMode());
|
||||
if (candleType != null) {
|
||||
row.setCandleType(candleType);
|
||||
}
|
||||
enrichStrategyTimeframes(row);
|
||||
LiveStrategySettingsDto saved = upsertEntity(userId, deviceId, row);
|
||||
if (active) {
|
||||
pinIfReady(symbol, saved);
|
||||
@@ -193,15 +177,21 @@ public class LiveStrategySettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 관심종목 중 이미 저장된 평가 분봉 (전역 템플릿 동기화용) */
|
||||
private String resolveSharedCandleType(Long userId, String deviceId) {
|
||||
for (String symbol : listWatchlistSymbols(userId, deviceId)) {
|
||||
Optional<GcLiveStrategySettings> row = findEntity(userId, deviceId, symbol);
|
||||
if (row.isPresent() && row.get().getCandleType() != null) {
|
||||
return LiveStrategyTimeframeService.normalize(row.get().getCandleType());
|
||||
private void enrichStrategyTimeframes(LiveStrategySettingsDto dto) {
|
||||
if (dto.getStrategyId() != null) {
|
||||
dto.setStrategyCandleTypes(
|
||||
conditionTimeframes.collectForStrategyList(dto.getStrategyId()));
|
||||
}
|
||||
}
|
||||
|
||||
private void pinAllStrategyTimeframes(String market, Long strategyId) {
|
||||
if (strategyId == null) return;
|
||||
subscriptionManager.ensureMarketPinned(market, "1m");
|
||||
for (String ct : conditionTimeframes.collectForStrategy(strategyId)) {
|
||||
if (!"1m".equals(ct)) {
|
||||
subscriptionManager.ensureMarketPinned(market, ct);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private LiveStrategySettingsDto upsertEntity(Long userId, String deviceId,
|
||||
@@ -222,8 +212,6 @@ public class LiveStrategySettingsService {
|
||||
"REALTIME_TICK".equals(dto.getExecutionType()) ? "REALTIME_TICK" : "CANDLE_CLOSE");
|
||||
entity.setPositionMode(
|
||||
"SIGNAL_ONLY".equals(dto.getPositionMode()) ? "SIGNAL_ONLY" : "LONG_ONLY");
|
||||
entity.setCandleType(LiveStrategyTimeframeService.normalize(
|
||||
dto.getCandleType() != null ? dto.getCandleType() : "1m"));
|
||||
|
||||
repo.save(entity);
|
||||
liveStrategyEvaluator.invalidateCache(market);
|
||||
@@ -232,12 +220,7 @@ public class LiveStrategySettingsService {
|
||||
|
||||
private void pinIfReady(String market, LiveStrategySettingsDto dto) {
|
||||
if (Boolean.TRUE.equals(dto.isLiveCheck()) && dto.getStrategyId() != null) {
|
||||
String ct = LiveStrategyTimeframeService.normalize(
|
||||
dto.getCandleType() != null ? dto.getCandleType() : "1m");
|
||||
subscriptionManager.ensureMarketPinned(market, "1m");
|
||||
if (!"1m".equals(ct)) {
|
||||
subscriptionManager.ensureMarketPinned(market, ct);
|
||||
}
|
||||
pinAllStrategyTimeframes(market, dto.getStrategyId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,14 +260,15 @@ public class LiveStrategySettingsService {
|
||||
}
|
||||
|
||||
private LiveStrategySettingsDto toDto(GcLiveStrategySettings e) {
|
||||
return LiveStrategySettingsDto.builder()
|
||||
LiveStrategySettingsDto dto = LiveStrategySettingsDto.builder()
|
||||
.market(e.getMarket())
|
||||
.strategyId(e.getStrategyId())
|
||||
.liveCheck(Boolean.TRUE.equals(e.getIsLiveCheck()))
|
||||
.executionType(e.getExecutionType())
|
||||
.positionMode(e.getPositionMode() != null ? e.getPositionMode() : "LONG_ONLY")
|
||||
.candleType(e.getCandleType() != null ? e.getCandleType() : "1m")
|
||||
.build();
|
||||
enrichStrategyTimeframes(dto);
|
||||
return dto;
|
||||
}
|
||||
|
||||
/** 앱 전역 실시간 체크 ON/OFF·전략 ID를 DTO에 반영 (종목별 행과 분리) */
|
||||
@@ -309,17 +293,6 @@ public class LiveStrategySettingsService {
|
||||
.liveCheck(Boolean.TRUE.equals(app.getLiveStrategyCheck()))
|
||||
.executionType(app.getLiveExecutionType() != null ? app.getLiveExecutionType() : "CANDLE_CLOSE")
|
||||
.positionMode(app.getLivePositionMode() != null ? app.getLivePositionMode() : "LONG_ONLY")
|
||||
.candleType(LiveStrategyTimeframeService.normalize(
|
||||
app.getDefaultTimeframe() != null
|
||||
? mapAppTf(app.getDefaultTimeframe()) : "1m"))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static String mapAppTf(String tf) {
|
||||
return switch (tf) {
|
||||
case "1m", "3m", "5m", "15m", "30m", "1h", "4h" -> tf;
|
||||
case "1D" -> "1d";
|
||||
default -> "1m";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.goldenchart.entity.GcStrategy;
|
||||
import com.goldenchart.repository.GcStrategyRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 전략 DSL(buy/sell LogicNode)에 포함된 조건 판별 시간봉 수집.
|
||||
* START / TIMEFRAME / AND|OR+TIMEFRAME 분기와 동일 규칙.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class StrategyConditionTimeframeService {
|
||||
|
||||
private final GcStrategyRepository strategyRepo;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private final ConcurrentHashMap<Long, Set<String>> cache = new ConcurrentHashMap<>();
|
||||
|
||||
public Set<String> collectForStrategy(long strategyId) {
|
||||
return cache.computeIfAbsent(strategyId, this::loadFromDb);
|
||||
}
|
||||
|
||||
public List<String> collectForStrategyList(long strategyId) {
|
||||
return List.copyOf(collectForStrategy(strategyId));
|
||||
}
|
||||
|
||||
public boolean usesTimeframe(long strategyId, String candleType) {
|
||||
String ct = LiveStrategyTimeframeService.normalize(candleType);
|
||||
return collectForStrategy(strategyId).contains(ct);
|
||||
}
|
||||
|
||||
public void invalidate(long strategyId) {
|
||||
cache.remove(strategyId);
|
||||
}
|
||||
|
||||
public void invalidateAll() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
private Set<String> loadFromDb(long strategyId) {
|
||||
Optional<GcStrategy> opt = strategyRepo.findById(strategyId);
|
||||
if (opt.isEmpty()) return Set.of("1m");
|
||||
GcStrategy strategy = opt.get();
|
||||
Set<String> out = new LinkedHashSet<>();
|
||||
collectFromJson(strategy.getBuyConditionJson(), out);
|
||||
collectFromJson(strategy.getSellConditionJson(), out);
|
||||
return out.isEmpty() ? Set.of("1m") : out;
|
||||
}
|
||||
|
||||
private void collectFromJson(String json, Set<String> out) {
|
||||
if (json == null || json.isBlank()) return;
|
||||
try {
|
||||
collectFromNode(objectMapper.readTree(json), out);
|
||||
} catch (Exception e) {
|
||||
log.warn("[StrategyTimeframes] JSON 파싱 실패: {}", e.getMessage());
|
||||
out.add("1m");
|
||||
}
|
||||
}
|
||||
|
||||
private void collectFromNode(JsonNode node, Set<String> out) {
|
||||
if (node == null || node.isNull()) return;
|
||||
String type = node.path("type").asText("");
|
||||
|
||||
if ("TIMEFRAME".equals(type)) {
|
||||
out.add(LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m")));
|
||||
return;
|
||||
}
|
||||
|
||||
if ("AND".equals(type) || "OR".equals(type)) {
|
||||
JsonNode children = node.path("children");
|
||||
if (children.isArray() && !children.isEmpty()) {
|
||||
if (allTimeframeChildren(children)) {
|
||||
for (JsonNode tf : children) {
|
||||
out.add(LiveStrategyTimeframeService.normalize(tf.path("candleType").asText("1m")));
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (JsonNode child : children) {
|
||||
collectFromNode(child, out);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
JsonNode child = node.path("child");
|
||||
if (!child.isMissingNode() && !child.isNull()) {
|
||||
collectFromNode(child, out);
|
||||
return;
|
||||
}
|
||||
|
||||
// 단일 START(기본 1m) 또는 TIMEFRAME 래핑 없는 트리
|
||||
out.add("1m");
|
||||
}
|
||||
|
||||
private static boolean allTimeframeChildren(JsonNode children) {
|
||||
for (JsonNode c : children) {
|
||||
if (!"TIMEFRAME".equals(c.path("type").asText(""))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public class StrategyService {
|
||||
|
||||
private final GcStrategyRepository repository;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||
|
||||
// ── 조회 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -61,13 +62,16 @@ public class StrategyService {
|
||||
log.warn("전략 DSL 직렬화 실패: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return toDto(repository.save(entity));
|
||||
GcStrategy saved = repository.save(entity);
|
||||
conditionTimeframes.invalidate(saved.getId());
|
||||
return toDto(saved);
|
||||
}
|
||||
|
||||
// ── 삭제 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
conditionTimeframes.invalidate(id);
|
||||
repository.deleteById(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.goldenchart.entity.GcLiveStrategySettings;
|
||||
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
||||
import com.goldenchart.service.LiveStrategyEvaluator;
|
||||
import com.goldenchart.service.LiveStrategyTimeframeService;
|
||||
import com.goldenchart.service.StrategyConditionTimeframeService;
|
||||
import com.goldenchart.service.TradeSignalService;
|
||||
import com.goldenchart.trading.pipeline.OrderExecutionQueue;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
@@ -46,6 +47,7 @@ public class BarBuilder {
|
||||
private final TradeSignalService tradeSignalService;
|
||||
private final OrderExecutionQueue orderExecutionQueue;
|
||||
private final GcLiveStrategySettingsRepository liveSettingsRepo;
|
||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||
|
||||
/** 상위 집계 타임프레임 (분 단위 집계 주기: 3, 5, 15, 30, 60, 240, 1440) */
|
||||
private static final int[] UPPER_MINUTES = {3, 5, 15, 30, 60, 240, 1440};
|
||||
@@ -155,12 +157,12 @@ public class BarBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/** 활성 실시간 전략 설정 중 해당 분봉(candleType)으로 평가하는 항목이 있는지 */
|
||||
/** 활성 실시간 전략 설정 중 전략 DSL에 해당 분봉이 포함된 항목이 있는지 */
|
||||
private boolean shouldEvaluateOnClose(String market, String candleType) {
|
||||
String ct = LiveStrategyTimeframeService.normalize(candleType);
|
||||
return liveSettingsRepo.findActiveByMarket(market).stream()
|
||||
.anyMatch(s -> ct.equals(LiveStrategyTimeframeService.normalize(
|
||||
s.getCandleType() != null ? s.getCandleType() : "1m")));
|
||||
.filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck()) && s.getStrategyId() != null)
|
||||
.anyMatch(s -> conditionTimeframes.usesTimeframe(s.getStrategyId(), ct));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@ import com.goldenchart.dto.CandleBarDto;
|
||||
import com.goldenchart.entity.GcLiveStrategySettings;
|
||||
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
||||
import com.goldenchart.service.LiveStrategyEvaluator;
|
||||
import com.goldenchart.service.LiveStrategyTimeframeService;
|
||||
import com.goldenchart.service.StrategyConditionTimeframeService;
|
||||
import com.goldenchart.service.TradeSignalService;
|
||||
import com.goldenchart.trading.pipeline.OrderExecutionQueue;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
@@ -15,7 +15,6 @@ import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 3초 주기 라운드 로빈 스케줄러.
|
||||
@@ -39,9 +38,9 @@ public class LiveStrategyScheduler {
|
||||
private final LiveStrategyEvaluator evaluator;
|
||||
private final Ta4jStorage ta4jStorage;
|
||||
private final TradingWebSocketBroker broker;
|
||||
private final BarBuilder barBuilder;
|
||||
private final TradeSignalService tradeSignalService;
|
||||
private final OrderExecutionQueue orderExecutionQueue;
|
||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||
|
||||
/**
|
||||
* 3초마다 REALTIME_TICK 설정 종목을 순회하여 전략 판정 후 시그널 발행.
|
||||
@@ -56,14 +55,19 @@ public class LiveStrategyScheduler {
|
||||
if (!"REALTIME_TICK".equals(s.getExecutionType())) continue;
|
||||
if (s.getStrategyId() == null) continue;
|
||||
|
||||
String market = s.getMarket();
|
||||
String candleType = LiveStrategyTimeframeService.normalize(
|
||||
s.getCandleType() != null ? s.getCandleType() : "1m");
|
||||
String market = s.getMarket();
|
||||
for (String candleType : conditionTimeframes.collectForStrategy(s.getStrategyId())) {
|
||||
evaluateRealtimeTickForMarket(s, market, candleType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!ta4jStorage.exists(market, candleType)) continue;
|
||||
private void evaluateRealtimeTickForMarket(GcLiveStrategySettings s, String market,
|
||||
String candleType) {
|
||||
if (!ta4jStorage.exists(market, candleType)) return;
|
||||
|
||||
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
|
||||
if (series.isEmpty()) continue;
|
||||
if (series.isEmpty()) return;
|
||||
|
||||
String signal = evaluator.evaluateRealtimeTick(market, candleType);
|
||||
if ("BUY".equals(signal) || "SELL".equals(signal)) {
|
||||
@@ -105,6 +109,5 @@ public class LiveStrategyScheduler {
|
||||
log.warn("[LiveStrategyScheduler] 시그널 DB 저장 실패: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
import com.goldenchart.trading.pipeline.TradingPipelineProperties;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.ta4j.core.BaseBarSeriesBuilder;
|
||||
import org.ta4j.core.Rule;
|
||||
import org.ta4j.core.TradingRecord;
|
||||
import org.ta4j.core.bars.TimeBarBuilderFactory;
|
||||
import org.ta4j.core.num.DoubleNumFactory;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 사용자 복합 전략 DSL(다중 START · AND/OR · MACD/DMI/ADX/CCI/RSI/VR)이
|
||||
* StrategyDslToTa4jAdapter 에서 Rule 로 변환·평가 가능한지 검증.
|
||||
*/
|
||||
class ComplexStrategyDslAdapterTest {
|
||||
|
||||
private static final String MARKET = "KRW-BTC";
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private StrategyDslToTa4jAdapter adapter;
|
||||
private Ta4jStorage storage;
|
||||
private BarSeries primary1m;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
adapter = new StrategyDslToTa4jAdapter();
|
||||
TradingPipelineProperties props = new TradingPipelineProperties();
|
||||
props.setEnabled(false);
|
||||
storage = new Ta4jStorage(props);
|
||||
primary1m = buildSyntheticSeries("1m", 260);
|
||||
seedStorage(MARKET, "1m", primary1m);
|
||||
seedStorage(MARKET, "3m", buildSyntheticSeries("3m", 120));
|
||||
seedStorage(MARKET, "5m", buildSyntheticSeries("5m", 80));
|
||||
}
|
||||
|
||||
/** Logic Expression 예시와 동일 구조의 매수 조건 */
|
||||
@Test
|
||||
void buyCondition_compilesAndEvaluates() throws Exception {
|
||||
JsonNode buy = MAPPER.readTree("""
|
||||
{
|
||||
"id": "buy-root",
|
||||
"type": "OR",
|
||||
"children": [
|
||||
{
|
||||
"id": "buy-and-1",
|
||||
"type": "AND",
|
||||
"children": [
|
||||
{
|
||||
"id": "c-macd",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "MACD",
|
||||
"conditionType": "CROSS_UP",
|
||||
"leftField": "MACD_LINE",
|
||||
"rightField": "SIGNAL_LINE",
|
||||
"candleRange": 1,
|
||||
"period": 12
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c-dmi",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "DMI",
|
||||
"conditionType": "CROSS_UP",
|
||||
"leftField": "PDI",
|
||||
"rightField": "MDI",
|
||||
"candleRange": 1,
|
||||
"period": 14
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "buy-and-2",
|
||||
"type": "AND",
|
||||
"children": [
|
||||
{
|
||||
"id": "c-adx",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "ADX",
|
||||
"conditionType": "CROSS_UP",
|
||||
"leftField": "ADX_VALUE",
|
||||
"rightField": "K_25",
|
||||
"candleRange": 1,
|
||||
"period": 14
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c-cci",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "CCI",
|
||||
"conditionType": "CROSS_UP",
|
||||
"leftField": "CCI_VALUE",
|
||||
"rightField": "K_100",
|
||||
"candleRange": 1,
|
||||
"period": 13
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Rule rule = adapter.toRule(buy, primary1m, Map.of(), MARKET, storage);
|
||||
assertNotNull(rule);
|
||||
assertDoesNotThrow(() -> evaluateRange(rule, primary1m.getEndIndex()));
|
||||
}
|
||||
|
||||
/** Logic Expression 예시와 동일 구조의 매도 조건 (1m AND 5m AND 3m) */
|
||||
@Test
|
||||
void sellCondition_multiTimeframe_compilesAndEvaluates() throws Exception {
|
||||
JsonNode sell = MAPPER.readTree("""
|
||||
{
|
||||
"id": "sell-root",
|
||||
"type": "AND",
|
||||
"children": [
|
||||
{
|
||||
"id": "tf-1m",
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "1m",
|
||||
"children": [{
|
||||
"id": "sell-1m-and",
|
||||
"type": "AND",
|
||||
"children": [
|
||||
{
|
||||
"id": "c-rsi-1m",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "RSI",
|
||||
"conditionType": "CROSS_DOWN",
|
||||
"leftField": "RSI_VALUE",
|
||||
"rightField": "K_70",
|
||||
"candleRange": 1,
|
||||
"period": 9
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c-macd-1m",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "MACD",
|
||||
"conditionType": "CROSS_DOWN",
|
||||
"leftField": "MACD_LINE",
|
||||
"rightField": "SIGNAL_LINE",
|
||||
"candleRange": 1,
|
||||
"period": 12
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c-vr-1m",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "VR",
|
||||
"conditionType": "CROSS_DOWN",
|
||||
"leftField": "VR_VALUE",
|
||||
"rightField": "K_200",
|
||||
"candleRange": 1,
|
||||
"period": 10
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
},
|
||||
{
|
||||
"id": "tf-5m",
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "5m",
|
||||
"children": [{
|
||||
"id": "sell-5m-and",
|
||||
"type": "AND",
|
||||
"children": [
|
||||
{
|
||||
"id": "c-adx-5m",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "ADX",
|
||||
"conditionType": "CROSS_DOWN",
|
||||
"leftField": "ADX_VALUE",
|
||||
"rightField": "K_28",
|
||||
"candleRange": 1,
|
||||
"period": 14
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c-cci-5m",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "CCI",
|
||||
"conditionType": "CROSS_DOWN",
|
||||
"leftField": "CCI_VALUE",
|
||||
"rightField": "K_26",
|
||||
"candleRange": 1,
|
||||
"period": 13
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
},
|
||||
{
|
||||
"id": "tf-3m",
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "3m",
|
||||
"children": [{
|
||||
"id": "sell-3m-and",
|
||||
"type": "AND",
|
||||
"children": [
|
||||
{
|
||||
"id": "c-rsi-3m",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "RSI",
|
||||
"conditionType": "CROSS_DOWN",
|
||||
"leftField": "RSI_VALUE",
|
||||
"rightField": "K_20",
|
||||
"candleRange": 1,
|
||||
"period": 9
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c-vr-3m",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "VR",
|
||||
"conditionType": "CROSS_DOWN",
|
||||
"leftField": "VR_VALUE",
|
||||
"rightField": "K_20",
|
||||
"candleRange": 1,
|
||||
"period": 10
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Rule rule = adapter.toRule(sell, primary1m, Map.of(), MARKET, storage);
|
||||
assertNotNull(rule);
|
||||
assertDoesNotThrow(() -> evaluateRange(rule, primary1m.getEndIndex()));
|
||||
|
||||
// CrossSeriesRule: 1m 트리거 인덱스에서 5m·3m 시리즈 endIndex 로 평가
|
||||
int idx = primary1m.getEndIndex();
|
||||
boolean result = rule.isSatisfied(idx, null);
|
||||
assertTrue(result || !result, "다중 시간봉 AND Rule 평가가 예외 없이 boolean 반환");
|
||||
}
|
||||
|
||||
/** 백테스트 경로( storage 미전달 ) — 단일 시리즈 fallback 동작 확인 */
|
||||
@Test
|
||||
void sellCondition_backtestSingleSeries_fallback() throws Exception {
|
||||
JsonNode sell = MAPPER.readTree("""
|
||||
{
|
||||
"type": "AND",
|
||||
"children": [
|
||||
{ "type": "TIMEFRAME", "candleType": "1m", "children": [
|
||||
{ "type": "CONDITION", "condition": {
|
||||
"indicatorType": "RSI", "conditionType": "CROSS_DOWN",
|
||||
"leftField": "RSI_VALUE", "rightField": "K_70", "period": 9
|
||||
}}
|
||||
]},
|
||||
{ "type": "TIMEFRAME", "candleType": "5m", "children": [
|
||||
{ "type": "CONDITION", "condition": {
|
||||
"indicatorType": "ADX", "conditionType": "CROSS_DOWN",
|
||||
"leftField": "ADX_VALUE", "rightField": "K_28", "period": 14
|
||||
}}
|
||||
]}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Rule rule = adapter.toRule(sell, primary1m, Map.of());
|
||||
assertNotNull(rule);
|
||||
assertDoesNotThrow(() -> rule.isSatisfied(primary1m.getEndIndex(), null));
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static void evaluateRange(Rule rule, int endIndex) {
|
||||
TradingRecord record = null;
|
||||
int start = Math.max(30, endIndex - 20);
|
||||
for (int i = start; i <= endIndex; i++) {
|
||||
rule.isSatisfied(i, record);
|
||||
}
|
||||
}
|
||||
|
||||
private static BarSeries buildSyntheticSeries(String timeframe, int barCount) {
|
||||
Duration period = switch (timeframe) {
|
||||
case "3m" -> Duration.ofMinutes(3);
|
||||
case "5m" -> Duration.ofMinutes(5);
|
||||
default -> Duration.ofMinutes(1);
|
||||
};
|
||||
BarSeries series = new BaseBarSeriesBuilder()
|
||||
.withName("test-" + timeframe)
|
||||
.withNumFactory(DoubleNumFactory.getInstance())
|
||||
.withBarBuilderFactory(new TimeBarBuilderFactory())
|
||||
.build();
|
||||
TimeBarBuilderFactory factory = new TimeBarBuilderFactory();
|
||||
Instant base = Instant.parse("2024-06-01T00:00:00Z");
|
||||
for (int i = 0; i < barCount; i++) {
|
||||
double wave = Math.sin(i * 0.07) * 8 + Math.cos(i * 0.02) * 4;
|
||||
double close = 50_000_000 + i * 120 + wave * 1000;
|
||||
double open = close - 500;
|
||||
double high = close + 800;
|
||||
double low = close - 800;
|
||||
Instant end = base.plus(period.multipliedBy(i + 1L));
|
||||
factory.createBarBuilder(series)
|
||||
.timePeriod(period)
|
||||
.endTime(end)
|
||||
.openPrice(open).highPrice(high).lowPrice(low).closePrice(close)
|
||||
.volume(12.5 + i * 0.01)
|
||||
.add();
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
private void seedStorage(String market, String candleType, BarSeries source) {
|
||||
for (int i = source.getBeginIndex(); i <= source.getEndIndex(); i++) {
|
||||
storage.addBar(market, candleType, source.getBar(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user