전략조건 상세설명 기능 추가

This commit is contained in:
Macbook
2026-05-25 03:15:05 +09:00
parent 30dedc4abc
commit 67324ded9d
22 changed files with 1151 additions and 187 deletions
@@ -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);
}