package com.goldenchart.service; import com.goldenchart.dto.LiveStrategyBulkRequest; import com.goldenchart.dto.LiveStrategySettingsDto; 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.websocket.DynamicSubscriptionManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @Service @RequiredArgsConstructor @Slf4j @Transactional public class LiveStrategySettingsService { private final GcLiveStrategySettingsRepository repo; private final GcWatchlistRepository watchlistRepo; private final AppSettingsService appSettingsService; private final DynamicSubscriptionManager subscriptionManager; private final LiveStrategyEvaluator liveStrategyEvaluator; private final StrategyConditionTimeframeService conditionTimeframes; // ── 조회 ────────────────────────────────────────────────────────────────── @Transactional(readOnly = true) public LiveStrategySettingsDto get(Long userId, String deviceId, String market) { GcAppSettings app = appSettingsService.getEntity(userId, deviceId); Optional entity = findEntity(userId, deviceId, market); LiveStrategySettingsDto dto = entity.isPresent() ? toDto(entity.get()) : defaultDtoFromApp(market, app); // 실시간 체크 마스터 스위치·전략 ID는 앱 전역 설정(gc_app_settings) 기준 dto = mergeGlobalTemplate(dto, app, market); enrichStrategyTimeframes(dto); return dto; } @Transactional(readOnly = true) public List listActive(Long userId, String deviceId) { GcAppSettings app = appSettingsService.getEntity(userId, deviceId); if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) || app.getLiveStrategyId() == null) { return List.of(); } List watchlist = listWatchlistSymbols(userId, deviceId); Long strategyId = app.getLiveStrategyId(); List strategyCandleTypes = conditionTimeframes.collectForStrategyList(strategyId); return watchlist.stream() .map(m -> LiveStrategySettingsDto.builder() .market(m) .strategyId(strategyId) .liveCheck(true) .executionType(app.getLiveExecutionType()) .positionMode(app.getLivePositionMode()) .strategyCandleTypes(strategyCandleTypes) .build()) .toList(); } // ── 관심종목 연동 (WatchlistService 에서 호출) ───────────────────────────── /** 관심종목 등록 시 — DB 관심종목 = 전략 체크 대상 행 생성/갱신 */ public void enableForWatchlistMarket(Long userId, String deviceId, String market) { GcAppSettings app = appSettingsService.getEntity(userId, deviceId); LiveStrategySettingsDto template = findEntity(userId, deviceId, market) .map(e -> mergeGlobalTemplate(toDto(e), 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 {}", market); } /** 관심종목 해제 시 — 해당 종목 전략 체크 비활성화 */ public void disableForWatchlistMarket(Long userId, String deviceId, String market) { findEntity(userId, deviceId, market).ifPresent(entity -> { entity.setIsLiveCheck(false); repo.save(entity); liveStrategyEvaluator.invalidateCache(market); }); log.debug("[LiveStrategy] watchlist remove → disabled {}", market); } // ── 일괄 저장 (레거시 API) ───────────────────────────────────────────────── public List saveBulk(Long userId, String deviceId, LiveStrategyBulkRequest req) { if (req.getMarkets() == null || req.getMarkets().isEmpty()) { return List.of(); } LiveStrategySettingsDto template = req.getTemplate() != null ? req.getTemplate() : defaultDtoFromApp("KRW-BTC", appSettingsService.getEntity(userId, deviceId)); persistGlobalTemplate(userId, deviceId, template); List results = new ArrayList<>(); for (String market : req.getMarkets()) { if (market == null || market.isBlank()) continue; LiveStrategySettingsDto one = LiveStrategySettingsDto.builder() .market(market) .strategyId(template.getStrategyId()) .liveCheck(true) .executionType(template.getExecutionType()) .positionMode(template.getPositionMode()) .build(); results.add(upsertEntity(userId, deviceId, one)); } log.info("[LiveStrategySettings] bulk save: {} markets", results.size()); return results; } // ── 저장/갱신 — 전역 템플릿 + 관심종목 전체 동기화 ───────────────────────── public LiveStrategySettingsDto save(Long userId, String deviceId, LiveStrategySettingsDto dto) { persistGlobalTemplate(userId, deviceId, dto); syncAllWatchlistMarkets(userId, deviceId, dto); String market = dto.getMarket() != null ? dto.getMarket() : "KRW-BTC"; log.info("[LiveStrategySettings] global saved + watchlist sync, template market={}", market); GcAppSettings app = appSettingsService.getEntity(userId, deviceId); LiveStrategySettingsDto out = findEntity(userId, deviceId, market) .map(this::toDto) .orElseGet(() -> defaultDtoFromApp(market, app)); out = mergeGlobalTemplate(out, app, market); if (dto.getExecutionType() != null) { out.setExecutionType(dto.getExecutionType()); } if (dto.getPositionMode() != null) { out.setPositionMode(dto.getPositionMode()); } enrichStrategyTimeframes(out); return out; } // ── Private ─────────────────────────────────────────────────────────────── private void persistGlobalTemplate(Long userId, String deviceId, LiveStrategySettingsDto dto) { Map patch = new HashMap<>(); patch.put("liveStrategyCheck", dto.isLiveCheck()); patch.put("liveStrategyId", dto.getStrategyId()); patch.put("liveExecutionType", dto.getExecutionType()); patch.put("livePositionMode", dto.getPositionMode()); appSettingsService.save(userId, deviceId, patch); } private void syncAllWatchlistMarkets(Long userId, String deviceId, LiveStrategySettingsDto template) { GcAppSettings app = appSettingsService.getEntity(userId, deviceId); boolean active = Boolean.TRUE.equals(app.getLiveStrategyCheck()) && app.getLiveStrategyId() != null; for (String symbol : listWatchlistSymbols(userId, deviceId)) { LiveStrategySettingsDto row = findEntity(userId, deviceId, symbol) .map(e -> mergeGlobalTemplate(toDto(e), app, symbol)) .orElseGet(() -> defaultDtoFromApp(symbol, app)); row.setLiveCheck(active); row.setStrategyId(app.getLiveStrategyId()); row.setExecutionType(app.getLiveExecutionType() != null ? app.getLiveExecutionType() : row.getExecutionType()); row.setPositionMode(app.getLivePositionMode() != null ? app.getLivePositionMode() : row.getPositionMode()); enrichStrategyTimeframes(row); LiveStrategySettingsDto saved = upsertEntity(userId, deviceId, row); if (active) { pinIfReady(symbol, saved); } } } 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); } } } private LiveStrategySettingsDto upsertEntity(Long userId, String deviceId, LiveStrategySettingsDto dto) { String market = dto.getMarket() != null ? dto.getMarket() : "KRW-BTC"; GcLiveStrategySettings entity = findEntity(userId, deviceId, market) .orElseGet(() -> GcLiveStrategySettings.builder() .userId(userId) .deviceId(userId == null ? deviceId : null) .market(market) .build()); applyOwnerKeys(entity, userId, deviceId); entity.setStrategyId(dto.getStrategyId()); entity.setIsLiveCheck(dto.isLiveCheck()); entity.setExecutionType( "REALTIME_TICK".equals(dto.getExecutionType()) ? "REALTIME_TICK" : "CANDLE_CLOSE"); entity.setPositionMode( "SIGNAL_ONLY".equals(dto.getPositionMode()) ? "SIGNAL_ONLY" : "LONG_ONLY"); repo.save(entity); liveStrategyEvaluator.invalidateCache(market); return toDto(entity); } private void pinIfReady(String market, LiveStrategySettingsDto dto) { if (Boolean.TRUE.equals(dto.isLiveCheck()) && dto.getStrategyId() != null) { pinAllStrategyTimeframes(market, dto.getStrategyId()); } } private List listWatchlistSymbols(Long userId, String deviceId) { List list = userId != null ? watchlistRepo.findByUserIdOrderByDisplayOrderAsc(userId) : watchlistRepo.findByDeviceIdOrderByDisplayOrderAsc( deviceId != null ? deviceId : "anonymous"); return list.stream().map(GcWatchlist::getSymbol).toList(); } /** * user_id·device_id 어느 쪽으로 저장됐든 동일 마켓 행을 찾는다. * (게스트 → 로그인 전환 시 device_id 행만 있어도 upsert 가 INSERT 로 중복 나지 않게) */ private Optional findEntity(Long userId, String deviceId, String market) { if (userId != null) { Optional byUser = repo.findByUserIdAndMarket(userId, market); if (byUser.isPresent()) return byUser; } if (deviceId != null && !deviceId.isBlank()) { return repo.findByDeviceIdAndMarket(deviceId, market); } return Optional.empty(); } /** 회원: user_id 전용 행 / 비회원: device_id 전용 행 (UK 충돌 방지) */ private void applyOwnerKeys(GcLiveStrategySettings entity, Long userId, String deviceId) { if (userId != null) { entity.setUserId(userId); entity.setDeviceId(null); } else { entity.setUserId(null); entity.setDeviceId(deviceId != null && !deviceId.isBlank() ? deviceId : null); } } private LiveStrategySettingsDto toDto(GcLiveStrategySettings e) { 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") .build(); enrichStrategyTimeframes(dto); return dto; } /** 앱 전역 실시간 체크 ON/OFF·전략 ID를 DTO에 반영 (종목별 행과 분리) */ private LiveStrategySettingsDto mergeGlobalTemplate( LiveStrategySettingsDto dto, GcAppSettings app, String market) { dto.setMarket(market); dto.setLiveCheck(Boolean.TRUE.equals(app.getLiveStrategyCheck())); dto.setStrategyId(app.getLiveStrategyId()); if (app.getLiveExecutionType() != null) { dto.setExecutionType(app.getLiveExecutionType()); } if (app.getLivePositionMode() != null) { dto.setPositionMode(app.getLivePositionMode()); } return dto; } private LiveStrategySettingsDto defaultDtoFromApp(String market, GcAppSettings app) { return LiveStrategySettingsDto.builder() .market(market) .strategyId(app.getLiveStrategyId()) .liveCheck(Boolean.TRUE.equals(app.getLiveStrategyCheck())) .executionType(app.getLiveExecutionType() != null ? app.getLiveExecutionType() : "CANDLE_CLOSE") .positionMode(app.getLivePositionMode() != null ? app.getLivePositionMode() : "LONG_ONLY") .build(); } }