goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
@@ -0,0 +1,126 @@
package com.goldenchart.service;
import com.goldenchart.auth.UserRole;
import com.goldenchart.dto.*;
import com.goldenchart.entity.GcUser;
import com.goldenchart.repository.GcUserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@Service
@RequiredArgsConstructor
public class AdminService {
private final GcUserRepository userRepo;
private final PasswordEncoder passwordEncoder;
private final RolePermissionService rolePermissionService;
@Transactional(readOnly = true)
public void verifyAdminPassword(Long userId, String password) {
rolePermissionService.requireAdmin(userId);
if (password == null || password.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "비밀번호를 입력하세요.");
}
GcUser user = userRepo.findById(userId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "사용자를 찾을 수 없습니다."));
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "관리자 비밀번호가 올바르지 않습니다.");
}
}
@Transactional(readOnly = true)
public List<UserDto> listUsers() {
return userRepo.findAll().stream().map(this::toDto).toList();
}
@Transactional
public UserDto createUser(CreateUserRequest req) {
if (req.getUsername() == null || req.getUsername().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "아이디를 입력하세요.");
}
if (req.getPassword() == null || req.getPassword().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "비밀번호를 입력하세요.");
}
String username = req.getUsername().trim();
if (userRepo.findByUsername(username).isPresent()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 사용 중인 아이디입니다.");
}
String role = UserRole.fromString(req.getRole() != null ? req.getRole() : UserRole.USER.name()).name();
GcUser user = GcUser.builder()
.username(username)
.passwordHash(passwordEncoder.encode(req.getPassword()))
.displayName(req.getDisplayName() != null ? req.getDisplayName().trim() : username)
.role(role)
.enabled(req.getEnabled() == null || req.getEnabled())
.build();
return toDto(userRepo.save(user));
}
@Transactional
public UserDto updateUser(Long id, UpdateUserRequest req, Long actorId) {
GcUser user = userRepo.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."));
if (req.getDisplayName() != null) {
user.setDisplayName(req.getDisplayName().trim());
}
if (req.getRole() != null) {
user.setRole(UserRole.fromString(req.getRole()).name());
}
if (req.getEnabled() != null) {
user.setEnabled(req.getEnabled());
}
if (req.getPassword() != null && !req.getPassword().isBlank()) {
user.setPasswordHash(passwordEncoder.encode(req.getPassword()));
}
// 마지막 ADMIN 비활성화/강등 방지
if (UserRole.ADMIN.name().equalsIgnoreCase(user.getRole())
&& (!Boolean.TRUE.equals(user.getEnabled())
|| (req.getRole() != null && !UserRole.ADMIN.name().equalsIgnoreCase(req.getRole())))) {
long adminCount = userRepo.findAll().stream()
.filter(u -> UserRole.ADMIN.name().equalsIgnoreCase(u.getRole())
&& Boolean.TRUE.equals(u.getEnabled())
&& !u.getId().equals(id))
.count();
if (adminCount == 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "최소 한 명의 활성 관리자가 필요합니다.");
}
}
return toDto(userRepo.save(user));
}
@Transactional
public void deleteUser(Long id, Long actorId) {
if (id.equals(actorId)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "본인 계정은 삭제할 수 없습니다.");
}
GcUser user = userRepo.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."));
if (UserRole.ADMIN.name().equalsIgnoreCase(user.getRole())) {
long adminCount = userRepo.findAll().stream()
.filter(u -> UserRole.ADMIN.name().equalsIgnoreCase(u.getRole())
&& Boolean.TRUE.equals(u.getEnabled()))
.count();
if (adminCount <= 1) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "최소 한 명의 관리자가 필요합니다.");
}
}
userRepo.delete(user);
}
private UserDto toDto(GcUser u) {
return UserDto.builder()
.id(u.getId())
.username(u.getUsername())
.displayName(u.getDisplayName() != null ? u.getDisplayName() : u.getUsername())
.role(u.getRole() != null ? u.getRole() : UserRole.USER.name())
.enabled(u.getEnabled())
.createdAt(u.getCreatedAt() != null ? u.getCreatedAt().toString() : null)
.build();
}
}
@@ -0,0 +1,263 @@
package com.goldenchart.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goldenchart.dto.UpbitApiCredentials;
import com.goldenchart.entity.GcAppSettings;
import com.goldenchart.repository.GcAppSettingsRepository;
import com.goldenchart.security.SecretCryptoService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* 앱 전역 차트 기본 설정 서비스.
*
* <p>프론트엔드에서 하드코딩된 기본값들(DEFAULT_STATE, DEFAULT_MAIN_CHART_STYLE,
* DEFAULT_SYNC 등)을 DB 로 대체하여 사용자별 맞춤 기본값을 관리한다.</p>
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class AppSettingsService {
private final GcAppSettingsRepository repo;
private final ObjectMapper mapper;
private final SecretCryptoService secretCrypto;
// ── 공개 API ─────────────────────────────────────────────────────────────
/**
* 현재 설정값을 Map 으로 반환.
* DB 에 없으면 엔티티 기본값이 반영된 빈 엔티티를 기반으로 Map 생성.
*/
@Transactional(readOnly = true)
public Map<String, Object> get(Long userId, String deviceId) {
GcAppSettings s = findEntity(userId, deviceId)
.orElse(new GcAppSettings());
return toMap(s);
}
/**
* 설정값을 Map 으로 받아 저장.
* DB 에 레코드가 없으면 신규 생성.
*/
public Map<String, Object> save(Long userId, String deviceId,
Map<String, Object> data) {
GcAppSettings s = findOrCreate(userId, deviceId);
apply(s, data);
repo.save(s);
log.debug("[AppSettings] saved for device={}", deviceId);
return toMap(s);
}
// ── Private helpers ───────────────────────────────────────────────────────
private Optional<GcAppSettings> findEntity(Long userId, String deviceId) {
if (userId != null) return repo.findByUserId(userId);
if (deviceId != null && !deviceId.isBlank()) return repo.findByDeviceId(deviceId);
return Optional.empty();
}
private GcAppSettings findOrCreate(Long userId, String deviceId) {
return findEntity(userId, deviceId)
.orElseGet(() -> GcAppSettings.builder()
.userId(userId)
.deviceId(deviceId)
.build());
}
private void apply(GcAppSettings s, Map<String, Object> d) {
if (d.containsKey("defaultSymbol")) s.setDefaultSymbol((String) d.get("defaultSymbol"));
if (d.containsKey("defaultTimeframe")) s.setDefaultTimeframe((String) d.get("defaultTimeframe"));
if (d.containsKey("defaultChartType")) s.setDefaultChartType((String) d.get("defaultChartType"));
if (d.containsKey("defaultTheme")) s.setDefaultTheme((String) d.get("defaultTheme"));
if (d.containsKey("defaultLogScale")) s.setDefaultLogScale(
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("mainChartStyle")) s.setMainChartStyleJson(toJson(d.get("mainChartStyle")));
if (d.containsKey("syncOptions")) s.setSyncOptionsJson(toJson(d.get("syncOptions")));
if (d.containsKey("btAutoPopup")) s.setBtAutoPopup(
Boolean.parseBoolean(d.get("btAutoPopup").toString()));
if (d.containsKey("btShowPrice")) s.setBtShowPrice(
Boolean.parseBoolean(d.get("btShowPrice").toString()));
if (d.containsKey("chartSeriesPriceLabels")) s.setChartSeriesPriceLabels(
Boolean.parseBoolean(d.get("chartSeriesPriceLabels").toString()));
if (d.containsKey("chartVolumeVisible")) s.setChartVolumeVisible(
Boolean.parseBoolean(d.get("chartVolumeVisible").toString()));
if (d.containsKey("chartLegendOptions")) s.setChartLegendOptionsJson(toJson(d.get("chartLegendOptions")));
if (d.containsKey("tradeAlertPopup")) s.setTradeAlertPopup(
Boolean.parseBoolean(d.get("tradeAlertPopup").toString()));
if (d.containsKey("tradeAlertSoundEnabled")) s.setTradeAlertSoundEnabled(
Boolean.parseBoolean(d.get("tradeAlertSoundEnabled").toString()));
if (d.containsKey("tradeAlertSound")) s.setTradeAlertSound(
d.get("tradeAlertSound").toString());
if (d.containsKey("tradeAlertPopupPosition")) {
String pos = d.get("tradeAlertPopupPosition").toString().toLowerCase();
s.setTradeAlertPopupPosition("left".equals(pos) || "bottom".equals(pos) ? pos : "right");
}
if (d.containsKey("tradeAlertPopupLayout")) {
String lay = d.get("tradeAlertPopupLayout").toString().toLowerCase();
s.setTradeAlertPopupLayout(
"grid".equals(lay) || "strip".equals(lay) || "single".equals(lay) ? lay : "stack");
}
if (d.containsKey("tradeAlertPopupGridCols")) {
int cols = Integer.parseInt(d.get("tradeAlertPopupGridCols").toString());
s.setTradeAlertPopupGridCols(Math.min(4, Math.max(2, cols)));
}
if (d.containsKey("liveStrategyCheck")) s.setLiveStrategyCheck(
Boolean.parseBoolean(d.get("liveStrategyCheck").toString()));
if (d.containsKey("liveStrategyId")) {
Object v = d.get("liveStrategyId");
s.setLiveStrategyId(v == null || "".equals(v.toString()) ? null : Long.parseLong(v.toString()));
}
if (d.containsKey("liveExecutionType")) s.setLiveExecutionType(
"REALTIME_TICK".equals(d.get("liveExecutionType")) ? "REALTIME_TICK" : "CANDLE_CLOSE");
if (d.containsKey("livePositionMode")) s.setLivePositionMode(
"SIGNAL_ONLY".equals(d.get("livePositionMode")) ? "SIGNAL_ONLY" : "LONG_ONLY");
if (d.containsKey("paperTradingEnabled")) s.setPaperTradingEnabled(
Boolean.parseBoolean(d.get("paperTradingEnabled").toString()));
if (d.containsKey("paperInitialCapital")) s.setPaperInitialCapital(
new java.math.BigDecimal(d.get("paperInitialCapital").toString()));
if (d.containsKey("paperFeeRatePct")) s.setPaperFeeRatePct(
new java.math.BigDecimal(d.get("paperFeeRatePct").toString()));
if (d.containsKey("paperSlippagePct")) s.setPaperSlippagePct(
new java.math.BigDecimal(d.get("paperSlippagePct").toString()));
if (d.containsKey("paperMinOrderKrw")) s.setPaperMinOrderKrw(
new java.math.BigDecimal(d.get("paperMinOrderKrw").toString()));
if (d.containsKey("paperAutoTradeEnabled")) s.setPaperAutoTradeEnabled(
Boolean.parseBoolean(d.get("paperAutoTradeEnabled").toString()));
if (d.containsKey("paperAutoTradeBudgetPct")) s.setPaperAutoTradeBudgetPct(
new java.math.BigDecimal(d.get("paperAutoTradeBudgetPct").toString()));
if (d.containsKey("tradingMode")) {
String mode = d.get("tradingMode").toString().toUpperCase();
s.setTradingMode("LIVE".equals(mode) || "BOTH".equals(mode) ? mode : "PAPER");
}
if (d.containsKey("liveAutoTradeEnabled")) s.setLiveAutoTradeEnabled(
Boolean.parseBoolean(d.get("liveAutoTradeEnabled").toString()));
if (d.containsKey("upbitAccessKey")) {
String v = d.get("upbitAccessKey").toString().trim();
if (!v.isEmpty() && !v.startsWith("····") && !v.startsWith("****")) {
s.setUpbitAccessKey(secretCrypto.encrypt(v));
}
}
if (d.containsKey("upbitSecretKey")) {
String v = d.get("upbitSecretKey").toString().trim();
if (!v.isEmpty() && !"__UNCHANGED__".equals(v)) {
s.setUpbitSecretKey(secretCrypto.encrypt(v));
}
}
if (d.containsKey("chartRealtimeSource")) {
String src = d.get("chartRealtimeSource").toString().toUpperCase();
s.setChartRealtimeSource("UPBIT_DIRECT".equals(src) ? "UPBIT_DIRECT" : "BACKEND_STOMP");
}
if (d.containsKey("liveAutoTradeBudgetPct")) s.setLiveAutoTradeBudgetPct(
new java.math.BigDecimal(d.get("liveAutoTradeBudgetPct").toString()));
if (d.containsKey("fcmPushEnabled")) s.setFcmPushEnabled(
Boolean.parseBoolean(d.get("fcmPushEnabled").toString()));
}
private Map<String, Object> toMap(GcAppSettings s) {
Map<String, Object> m = new HashMap<>();
m.put("defaultSymbol", s.getDefaultSymbol() != null ? s.getDefaultSymbol() : "KRW-BTC");
m.put("defaultTimeframe", s.getDefaultTimeframe() != null ? s.getDefaultTimeframe() : "1D");
m.put("defaultChartType", s.getDefaultChartType() != null ? s.getDefaultChartType() : "candlestick");
m.put("defaultTheme", s.getDefaultTheme() != null ? s.getDefaultTheme() : "dark");
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("mainChartStyle", parseJson(s.getMainChartStyleJson()));
m.put("syncOptions", parseJson(s.getSyncOptionsJson()));
m.put("btAutoPopup", s.getBtAutoPopup() != null ? s.getBtAutoPopup() : true);
m.put("btShowPrice", s.getBtShowPrice() != null ? s.getBtShowPrice() : true);
m.put("chartSeriesPriceLabels", s.getChartSeriesPriceLabels() != null ? s.getChartSeriesPriceLabels() : true);
m.put("chartVolumeVisible", s.getChartVolumeVisible() != null ? s.getChartVolumeVisible() : true);
m.put("chartLegendOptions", parseJson(s.getChartLegendOptionsJson()));
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");
m.put("tradeAlertPopupPosition", s.getTradeAlertPopupPosition() != null ? s.getTradeAlertPopupPosition() : "right");
m.put("tradeAlertPopupLayout", s.getTradeAlertPopupLayout() != null ? s.getTradeAlertPopupLayout() : "stack");
m.put("tradeAlertPopupGridCols", s.getTradeAlertPopupGridCols() != null ? s.getTradeAlertPopupGridCols() : 2);
m.put("liveStrategyCheck", s.getLiveStrategyCheck() != null ? s.getLiveStrategyCheck() : false);
m.put("liveStrategyId", s.getLiveStrategyId());
m.put("liveExecutionType", s.getLiveExecutionType() != null ? s.getLiveExecutionType() : "CANDLE_CLOSE");
m.put("livePositionMode", s.getLivePositionMode() != null ? s.getLivePositionMode() : "LONG_ONLY");
m.put("paperTradingEnabled", s.getPaperTradingEnabled() != null ? s.getPaperTradingEnabled() : true);
m.put("paperInitialCapital", s.getPaperInitialCapital() != null
? s.getPaperInitialCapital().doubleValue() : 10_000_000);
m.put("paperFeeRatePct", s.getPaperFeeRatePct() != null ? s.getPaperFeeRatePct().doubleValue() : 0.05);
m.put("paperSlippagePct", s.getPaperSlippagePct() != null ? s.getPaperSlippagePct().doubleValue() : 0);
m.put("paperMinOrderKrw", s.getPaperMinOrderKrw() != null ? s.getPaperMinOrderKrw().doubleValue() : 5000);
m.put("paperAutoTradeEnabled", s.getPaperAutoTradeEnabled() != null ? s.getPaperAutoTradeEnabled() : false);
m.put("paperAutoTradeBudgetPct", s.getPaperAutoTradeBudgetPct() != null
? s.getPaperAutoTradeBudgetPct().doubleValue() : 95);
m.put("tradingMode", s.getTradingMode() != null ? s.getTradingMode() : "PAPER");
m.put("liveAutoTradeEnabled", s.getLiveAutoTradeEnabled() != null ? s.getLiveAutoTradeEnabled() : false);
UpbitApiCredentials creds = resolveUpbitCredentials(s);
m.put("upbitAccessKeyMasked", SecretCryptoService.maskForDisplay(creds.accessKey()));
m.put("hasUpbitKeys", creds.isComplete());
m.put("chartRealtimeSource", s.getChartRealtimeSource() != null ? s.getChartRealtimeSource() : "BACKEND_STOMP");
m.put("liveAutoTradeBudgetPct", s.getLiveAutoTradeBudgetPct() != null
? s.getLiveAutoTradeBudgetPct().doubleValue() : 95);
m.put("fcmPushEnabled", s.getFcmPushEnabled() != null ? s.getFcmPushEnabled() : false);
return m;
}
/** 업비트 API 키 복호화 (실거래 API 호출 전용, 외부 노출 금지) */
@Transactional(readOnly = true)
public UpbitApiCredentials resolveUpbitCredentials(GcAppSettings s) {
if (s == null) {
return UpbitApiCredentials.builder().accessKey("").secretKey("").build();
}
String access = safeDecrypt(s.getUpbitAccessKey());
String secret = safeDecrypt(s.getUpbitSecretKey());
return UpbitApiCredentials.builder()
.accessKey(access != null ? access : "")
.secretKey(secret != null ? secret : "")
.build();
}
/** encryption-key 불일치 시 API 500 대신 키 미등록으로 처리 */
private String safeDecrypt(String stored) {
try {
return secretCrypto.decrypt(stored);
} catch (Exception e) {
log.warn("[AppSettings] 업비트 키 복호화 실패 — hasUpbitKeys=false 로 처리: {}", e.getMessage());
return "";
}
}
@Transactional(readOnly = true)
public boolean hasUpbitApiKeys(GcAppSettings s) {
return resolveUpbitCredentials(s).isComplete();
}
/** 실시간 전략 전역 템플릿 (관심종목 자동 연동용) */
@Transactional(readOnly = true)
public GcAppSettings getEntity(Long userId, String deviceId) {
return findEntity(userId, deviceId).orElse(new GcAppSettings());
}
private Object parseJson(String json) {
if (json == null || json.isBlank()) return null;
try { return mapper.readValue(json, Object.class); }
catch (Exception e) { return null; }
}
private String toJson(Object obj) {
if (obj == null) return null;
try { return mapper.writeValueAsString(obj); }
catch (JsonProcessingException e) {
log.warn("[AppSettings] JSON 직렬화 실패", e);
return null;
}
}
}
@@ -0,0 +1,68 @@
package com.goldenchart.service;
import com.goldenchart.auth.UserRole;
import com.goldenchart.dto.LoginResponse;
import com.goldenchart.entity.GcUser;
import com.goldenchart.repository.GcUserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class AuthService {
private final GcUserRepository userRepo;
private final PasswordEncoder passwordEncoder;
@Transactional(readOnly = true)
public LoginResponse login(String username, String password) {
if (username == null || username.isBlank() || password == null || password.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "아이디와 비밀번호를 입력하세요.");
}
GcUser user = userRepo.findByUsername(username.trim())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 올바르지 않습니다."));
if (!Boolean.TRUE.equals(user.getEnabled())) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "비활성화된 계정입니다.");
}
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 올바르지 않습니다.");
}
return toLoginResponse(user);
}
@Transactional(readOnly = true)
public Optional<LoginResponse> me(Long userId) {
if (userId == null) return Optional.empty();
return userRepo.findById(userId)
.filter(u -> Boolean.TRUE.equals(u.getEnabled()))
.map(this::toLoginResponse);
}
private LoginResponse toLoginResponse(GcUser user) {
return LoginResponse.builder()
.userId(user.getId())
.username(user.getUsername())
.displayName(user.getDisplayName() != null ? user.getDisplayName() : user.getUsername())
.role(user.getRole() != null ? user.getRole() : UserRole.USER.name())
.build();
}
/** 기본 관리자 계정 (admin / admin) — 없으면 생성 */
@Transactional
public void ensureDefaultAdmin() {
if (userRepo.findByUsername("admin").isPresent()) return;
userRepo.save(GcUser.builder()
.username("admin")
.passwordHash(passwordEncoder.encode("admin"))
.displayName("관리자")
.enabled(true)
.role(UserRole.ADMIN.name())
.build());
}
}
@@ -0,0 +1,84 @@
package com.goldenchart.service;
import com.goldenchart.dto.BacktestSettingsDto;
import com.goldenchart.entity.GcBacktestSettings;
import com.goldenchart.repository.GcBacktestSettingsRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class BacktestSettingsService {
private final GcBacktestSettingsRepository repo;
/** device_id 기준 설정 조회 — 없으면 기본값 반환 */
@Transactional(readOnly = true)
public BacktestSettingsDto get(String deviceId) {
return repo.findFirstByDeviceIdOrderByUpdatedAtDesc(deviceId)
.map(this::toDto)
.orElseGet(BacktestSettingsDto::new);
}
/** upsert — 기존 설정이 있으면 덮어쓰기, 없으면 새 행 삽입 */
@Transactional
public BacktestSettingsDto save(String deviceId, BacktestSettingsDto dto) {
GcBacktestSettings entity = repo
.findFirstByDeviceIdOrderByUpdatedAtDesc(deviceId)
.orElseGet(GcBacktestSettings::new);
entity.setDeviceId(deviceId);
entity.setInitialCapital(dto.getInitialCapital());
entity.setCommissionType(dto.getCommissionType());
entity.setCommissionRate(dto.getCommissionRate());
entity.setSlippageRate(dto.getSlippageRate());
entity.setEntryPriceType(dto.getEntryPriceType());
entity.setExitPriceType(dto.getExitPriceType());
entity.setPositionDirection(dto.getPositionDirection());
entity.setTradeSizeType(dto.getTradeSizeType());
entity.setTradeSizeValue(dto.getTradeSizeValue());
entity.setStopLossEnabled(dto.getStopLossEnabled());
entity.setStopLossPct(dto.getStopLossPct());
entity.setTakeProfitEnabled(dto.getTakeProfitEnabled());
entity.setTakeProfitPct(dto.getTakeProfitPct());
entity.setTrailingStopEnabled(dto.getTrailingStopEnabled());
entity.setTrailingStopPct(dto.getTrailingStopPct());
entity.setReentryWaitBars(dto.getReentryWaitBars());
entity.setMaxOpenTrades(dto.getMaxOpenTrades());
entity.setPartialExitEnabled(dto.getPartialExitEnabled());
entity.setPartialExitPct(dto.getPartialExitPct());
entity.setPositionMode(
"SIGNAL_ONLY".equals(dto.getPositionMode()) ? "SIGNAL_ONLY" : "LONG_ONLY");
return toDto(repo.save(entity));
}
// ── 변환 헬퍼 ─────────────────────────────────────────────────────────────
private BacktestSettingsDto toDto(GcBacktestSettings e) {
return BacktestSettingsDto.builder()
.id(e.getId())
.initialCapital(e.getInitialCapital())
.commissionType(e.getCommissionType())
.commissionRate(e.getCommissionRate())
.slippageRate(e.getSlippageRate())
.entryPriceType(e.getEntryPriceType())
.exitPriceType(e.getExitPriceType())
.positionDirection(e.getPositionDirection())
.tradeSizeType(e.getTradeSizeType())
.tradeSizeValue(e.getTradeSizeValue())
.stopLossEnabled(e.getStopLossEnabled())
.stopLossPct(e.getStopLossPct())
.takeProfitEnabled(e.getTakeProfitEnabled())
.takeProfitPct(e.getTakeProfitPct())
.trailingStopEnabled(e.getTrailingStopEnabled())
.trailingStopPct(e.getTrailingStopPct())
.reentryWaitBars(e.getReentryWaitBars())
.maxOpenTrades(e.getMaxOpenTrades())
.partialExitEnabled(e.getPartialExitEnabled())
.partialExitPct(e.getPartialExitPct())
.positionMode(e.getPositionMode() != null ? e.getPositionMode() : "LONG_ONLY")
.build();
}
}
@@ -0,0 +1,558 @@
package com.goldenchart.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goldenchart.dto.*;
import com.goldenchart.dto.BacktestResponse.Signal;
import com.goldenchart.dto.BacktestResponse.Stats;
import com.goldenchart.entity.GcBacktestResult;
import com.goldenchart.entity.GcStrategy;
import com.goldenchart.repository.GcBacktestResultRepository;
import com.goldenchart.repository.GcStrategyRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.ta4j.core.*;
import org.ta4j.core.bars.TimeBarBuilderFactory;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.num.Num;
import org.ta4j.core.rules.BooleanRule;
import org.ta4j.core.rules.OrRule;
import org.ta4j.core.rules.StopGainRule;
import org.ta4j.core.rules.StopLossRule;
import org.ta4j.core.rules.TrailingStopLossRule;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
/**
* Ta4j 기반 백테스팅 실행 서비스.
*
* <ul>
* <li>TradingRecord 를 정상 populate → AnalysisCriterion 전체 활용</li>
* <li>StopLoss / StopGain / TrailingStop / Commission / Slippage 반영</li>
* <li>실행 결과를 gc_backtest_result 테이블에 저장</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class BacktestingService {
private final StrategyDslToTa4jAdapter adapter;
private final GcStrategyRepository strategyRepository;
private final GcBacktestResultRepository resultRepository;
private final ObjectMapper objectMapper;
private static final BacktestSettingsDto DEFAULT_SETTINGS = new BacktestSettingsDto();
// ── Public API ────────────────────────────────────────────────────────────
public BacktestResponse run(BacktestRequest req) {
if (req.getBars() == null || req.getBars().isEmpty()) {
return emptyResponse("캔들 데이터가 없습니다.");
}
JsonNode buyDsl = req.getBuyCondition();
JsonNode sellDsl = req.getSellCondition();
String strategyName = req.getStrategyName() != null ? req.getStrategyName() : "전략";
if (req.getStrategyId() != null) {
Optional<GcStrategy> opt = strategyRepository.findById(req.getStrategyId());
if (opt.isEmpty()) return emptyResponse("전략을 찾을 수 없습니다: id=" + req.getStrategyId());
GcStrategy strat = opt.get();
strategyName = strat.getName() != null ? strat.getName() : strategyName;
try {
if (strat.getBuyConditionJson() != null)
buyDsl = objectMapper.readTree(strat.getBuyConditionJson());
if (strat.getSellConditionJson() != null)
sellDsl = objectMapper.readTree(strat.getSellConditionJson());
} catch (Exception e) {
log.warn("전략 DSL JSON 파싱 실패: {}", e.getMessage());
return emptyResponse("전략 DSL 파싱 오류");
}
}
Map<String, Map<String, Object>> params = req.getIndicatorParams() != null
? req.getIndicatorParams() : Map.of();
BacktestSettingsDto cfg = req.getSettings() != null ? req.getSettings() : DEFAULT_SETTINGS;
BarSeries series = buildSeries(req.getBars(), req.getTimeframe());
int n = series.getBarCount();
if (n == 0) return emptyResponse("유효한 캔들 데이터가 없습니다.");
Rule entryRule = adapter.toRule(buyDsl, series, params);
Rule baseExitRule = (sellDsl != null && !sellDsl.isNull())
? adapter.toRule(sellDsl, series, params)
: new BooleanRule(false);
Rule exitRule = buildExitRule(baseExitRule, series, cfg);
return runBacktest(series, entryRule, exitRule, req, cfg, strategyName);
}
// ── 청산 규칙 합성 ────────────────────────────────────────────────────────
private Rule buildExitRule(Rule baseExit, BarSeries series, BacktestSettingsDto cfg) {
Rule result = baseExit;
ClosePriceIndicator close = new ClosePriceIndicator(series);
if (Boolean.TRUE.equals(cfg.getStopLossEnabled())) {
double pct = cfg.getStopLossPct() != null ? cfg.getStopLossPct().doubleValue() : 2.0;
result = new OrRule(result, new StopLossRule(close, series.numFactory().numOf(pct)));
}
if (Boolean.TRUE.equals(cfg.getTakeProfitEnabled())) {
double pct = cfg.getTakeProfitPct() != null ? cfg.getTakeProfitPct().doubleValue() : 5.0;
result = new OrRule(result, new StopGainRule(close, series.numFactory().numOf(pct)));
}
if (Boolean.TRUE.equals(cfg.getTrailingStopEnabled())) {
double pct = cfg.getTrailingStopPct() != null ? cfg.getTrailingStopPct().doubleValue() : 2.0;
result = new OrRule(result, new TrailingStopLossRule(close, series.numFactory().numOf(pct)));
}
return result;
}
// ── 백테스팅 루프 ─────────────────────────────────────────────────────────
private BacktestResponse runBacktest(BarSeries series, Rule entryRule, Rule exitRule,
BacktestRequest req, BacktestSettingsDto cfg,
String strategyName) {
BaseTradingRecord record = new BaseTradingRecord();
List<Signal> signals = new ArrayList<>();
boolean inPosition = false;
double entryPrice = 0;
int entryBarIdx = -1;
int lastExitBar = -1;
int reentryWait = cfg.getReentryWaitBars() != null ? cfg.getReentryWaitBars() : 0;
String direction = cfg.getPositionDirection() != null ? cfg.getPositionDirection() : "LONG";
// positionMode: LONG_ONLY(기본) | SIGNAL_ONLY(포지션 락 우회)
String posMode = cfg.getPositionMode() != null ? cfg.getPositionMode() : "LONG_ONLY";
boolean signalOnly = "SIGNAL_ONLY".equals(posMode);
double initCap = cfg.getInitialCapital() != null ? cfg.getInitialCapital().doubleValue() : 10_000_000.0;
double tradeSizePct = cfg.getTradeSizeValue() != null ? cfg.getTradeSizeValue().doubleValue() / 100.0 : 1.0;
boolean partialExit = Boolean.TRUE.equals(cfg.getPartialExitEnabled());
double partialPct = cfg.getPartialExitPct() != null ? cfg.getPartialExitPct().doubleValue() / 100.0 : 0.5;
boolean partialDone = false;
double equity = initCap;
int barCount = series.getBarCount();
for (int i = 0; i < barCount; i++) {
double closePrice = getPrice(series, req.getBars(), i, cfg.getEntryPriceType());
double exitPrice = getPrice(series, req.getBars(), i, cfg.getExitPriceType());
long time = series.getBar(i).getEndTime().getEpochSecond();
// ── SIGNAL_ONLY 모드: 포지션 상태와 무관하게 순수 지표 규칙 충족 여부만 판정 ──
if (signalOnly) {
boolean enterOk = entryRule.isSatisfied(i);
boolean exitOk = exitRule.isSatisfied(i);
if (enterOk) {
double effEntry = applySlippage(closePrice, cfg, true);
String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY";
signals.add(Signal.builder()
.time(time).type(sigType).price(effEntry).barIndex(i).build());
// 실제 포지션 추적은 LONG_ONLY 모드와 동일하게 유지 (수익 계산용)
if (!inPosition) {
double shares = (equity * tradeSizePct) / effEntry;
record.enter(i, series.numFactory().numOf(effEntry), series.numFactory().numOf(shares));
entryPrice = effEntry;
entryBarIdx = i;
inPosition = true;
partialDone = false;
}
} else if (exitOk) {
double effExit = applySlippage(exitPrice, cfg, false);
String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL";
signals.add(Signal.builder()
.time(time).type(sigType).price(effExit).barIndex(i).build());
// 수익 계산: 실제 포지션이 있을 때만
if (inPosition) {
double commission = calcCommissionRate(cfg) * 2;
double rawReturn = "SHORT".equals(direction)
? (entryPrice - effExit) / entryPrice
: (effExit - entryPrice) / entryPrice;
double size = partialDone ? (1.0 - partialPct) : 1.0;
equity += equity * tradeSizePct * size * (rawReturn - commission);
Num numExitPrice = series.numFactory().numOf(effExit);
Num numShares = record.getCurrentPosition().getEntry().getAmount();
record.exit(i, numExitPrice, numShares);
inPosition = false;
lastExitBar = i;
partialDone = false;
}
}
continue; // SIGNAL_ONLY 처리 완료, 다음 봉으로
}
// ── LONG_ONLY 모드: 표준 포지션 제약 로직 ────────────────────────────
if (!inPosition) {
if (i - lastExitBar <= reentryWait && lastExitBar >= 0) continue;
boolean doEnter = entryRule.isSatisfied(i, record);
if (!doEnter && "SHORT".equals(direction))
doEnter = exitRule.isSatisfied(i, record);
if (doEnter) {
double effEntry = applySlippage(closePrice, cfg, true);
double shares = (equity * tradeSizePct) / effEntry;
Num numPrice = series.numFactory().numOf(effEntry);
Num numShares = series.numFactory().numOf(shares);
record.enter(i, numPrice, numShares);
String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY";
signals.add(Signal.builder()
.time(time).type(sigType).price(effEntry).barIndex(i).build());
entryPrice = effEntry;
entryBarIdx = i;
inPosition = true;
partialDone = false;
}
} else {
// 분할 청산: exit 조건 처음 충족 시 일부만 청산
if (partialExit && !partialDone && exitRule.isSatisfied(i, record)) {
double effExit = applySlippage(exitPrice, cfg, false);
double partShares = record.getCurrentPosition().getEntry().getAmount().doubleValue() * partialPct;
double partReturn = "SHORT".equals(direction)
? (entryPrice - effExit) / entryPrice
: (effExit - entryPrice) / entryPrice;
double commission = calcCommissionRate(cfg) * 2;
equity += equity * tradeSizePct * partialPct * (partReturn - commission);
signals.add(Signal.builder()
.time(time).type("PARTIAL_SELL").price(effExit).barIndex(i).build());
partialDone = true;
continue;
}
if (exitRule.isSatisfied(i, record)) {
double effExit = applySlippage(exitPrice, cfg, false);
double commission = calcCommissionRate(cfg) * 2;
double rawReturn = "SHORT".equals(direction)
? (entryPrice - effExit) / entryPrice
: (effExit - entryPrice) / entryPrice;
double netReturn = rawReturn - commission;
double size = partialDone ? (1.0 - partialPct) : 1.0;
equity += equity * tradeSizePct * size * netReturn;
Num numExitPrice = series.numFactory().numOf(effExit);
Num numShares = record.getCurrentPosition().getEntry().getAmount();
record.exit(i, numExitPrice, numShares);
String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL";
signals.add(Signal.builder()
.time(time).type(sigType).price(effExit).barIndex(i).build());
inPosition = false;
lastExitBar = i;
partialDone = false;
}
}
}
// ── AnalysisCriterion 전체 계산 ───────────────────────────────────────
BacktestAnalysisDto analysis = calcAnalysis(series, record, cfg, initCap, equity);
// ── Stats (하위 호환) ─────────────────────────────────────────────────
Stats stats = toStats(analysis, signals);
// ── DB 저장 ───────────────────────────────────────────────────────────
Long resultId = saveResult(req, cfg, signals, analysis, series, strategyName);
return BacktestResponse.builder()
.signals(signals)
.stats(stats)
.analysis(analysis)
.resultId(resultId)
.build();
}
// ── Ta4j AnalysisCriterion 전체 계산 ─────────────────────────────────────
private BacktestAnalysisDto calcAnalysis(BarSeries series, TradingRecord record,
BacktestSettingsDto cfg, double initCap, double finalEquity) {
BacktestAnalysisDto.BacktestAnalysisDtoBuilder b = BacktestAnalysisDto.builder()
.initialCapital(initCap)
.finalEquity(finalEquity);
double totalReturnPct = safeCalc(() -> calcTotalReturnPct(series, record));
double grossProfit = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.pnl.GrossProfitCriterion", series, record));
double grossLoss = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.pnl.GrossLossCriterion", series, record));
double avgReturnPct = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.pnl.AverageProfitLossCriterion", series, record));
double profitLossRatio = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.pnl.ProfitLossRatioCriterion", series, record));
int positions = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfPositionsCriterion", series, record));
int winning = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfWinningPositionsCriterion", series, record));
int losing = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfLosingPositionsCriterion", series, record));
int breakEven = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfBreakEvenPositionsCriterion", series, record));
double winRate = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.WinningPositionsRatioCriterion", series, record));
double maxDrawdown = safeCalc(() -> calcMaxDrawdown(series, record));
double maxRunup = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.MaximumRunupCriterion", series, record));
double sharpe = safeCalc(() -> calcSharpeRatio(series, record));
double sortino = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.SortinoRatioCriterion", series, record));
double calmar = (maxDrawdown != 0) ? totalReturnPct / Math.abs(maxDrawdown) : 0.0;
double var95 = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.ValueAtRiskCriterion", series, record));
double es = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.ExpectedShortfallCriterion", series, record));
double buyHoldPct = safeCalc(() -> calcBuyAndHold(series, record));
double vsBuyHold = (buyHoldPct != 0) ? (1 + totalReturnPct) / (1 + buyHoldPct) : 0.0;
// 금액 기준 총 손익 재계산
double totalPnl = finalEquity - initCap;
// positions = 0 이면 record에서 직접 추출 시도
if (positions == 0) positions = record.getPositionCount();
if (winning == 0 && positions > 0) {
winning = (int) record.getPositions().stream().filter(p -> p.isClosed() && p.getProfit().isPositive()).count();
losing = (int) record.getPositions().stream().filter(p -> p.isClosed() && p.getProfit().isNegative()).count();
breakEven = positions - winning - losing;
}
if (winRate == 0 && positions > 0) winRate = (double) winning / positions;
return b
.totalReturnPct(totalReturnPct)
.totalProfitLoss(totalPnl)
.grossProfit(grossProfit)
.grossLoss(grossLoss)
.avgReturnPct(avgReturnPct)
.profitLossRatio(profitLossRatio)
.numberOfPositions(positions)
.numberOfWinning(winning)
.numberOfLosing(losing)
.numberOfBreakEven(breakEven)
.winRate(winRate)
.maxDrawdownPct(maxDrawdown)
.maxRunupPct(maxRunup)
.sharpeRatio(sharpe)
.sortinoRatio(sortino)
.calmarRatio(calmar)
.valueAtRisk95(var95)
.expectedShortfall(es)
.buyAndHoldReturnPct(buyHoldPct)
.vsBuyAndHold(vsBuyHold)
.build();
}
// ── 개별 Criterion 계산 헬퍼 ─────────────────────────────────────────────
private double calcTotalReturnPct(BarSeries series, TradingRecord record) throws Exception {
// 총 수익률 = (finalEquity - initialCapital) / initialCapital
// TotalProfitLossPercentageCriterion 또는 ReturnCriterion 시도
try {
Class<?> cls = Class.forName("org.ta4j.core.criteria.pnl.TotalProfitLossPercentageCriterion");
AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance();
return criterion.calculate(series, record).doubleValue();
} catch (Exception ignored) {}
try {
Class<?> cls = Class.forName("org.ta4j.core.criteria.ReturnCriterion");
AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance();
return criterion.calculate(series, record).doubleValue() - 1.0;
} catch (Exception ignored) {}
// fallback: 거래 수익 직접 계산
return record.getPositions().stream()
.filter(Position::isClosed)
.mapToDouble(p -> p.getProfit().doubleValue() / p.getEntry().getNetPrice().doubleValue())
.sum();
}
private double calcMaxDrawdown(BarSeries series, TradingRecord record) throws Exception {
try {
Class<?> cls = Class.forName("org.ta4j.core.criteria.MaximumDrawdownCriterion");
AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance();
return criterion.calculate(series, record).doubleValue();
} catch (Exception ignored) {}
return 0.0;
}
private double calcSharpeRatio(BarSeries series, TradingRecord record) throws Exception {
try {
Class<?> cls = Class.forName("org.ta4j.core.criteria.SharpeRatioCriterion");
AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance();
return criterion.calculate(series, record).doubleValue();
} catch (Exception ignored) {}
return 0.0;
}
private double calcBuyAndHold(BarSeries series, TradingRecord record) throws Exception {
try {
Class<?> cls = Class.forName("org.ta4j.core.criteria.EnterAndHoldReturnCriterion");
AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance();
double v = criterion.calculate(series, record).doubleValue();
// 일부 버전은 비율 반환, 일부는 배수 반환
return v > 10 ? v / 100.0 : v;
} catch (Exception ignored) {}
// fallback: 첫봉~마지막봉 종가 변화율
if (series.getBarCount() >= 2) {
double first = series.getBar(0).getClosePrice().doubleValue();
double last = series.getBar(series.getBarCount() - 1).getClosePrice().doubleValue();
return (last - first) / first;
}
return 0.0;
}
private double calcCriterion(String className, BarSeries series, TradingRecord record) throws Exception {
Class<?> cls = Class.forName(className);
AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance();
return criterion.calculate(series, record).doubleValue();
}
private int calcCriterionInt(String className, BarSeries series, TradingRecord record) throws Exception {
Class<?> cls = Class.forName(className);
AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance();
return (int) Math.round(criterion.calculate(series, record).doubleValue());
}
private double safeCalc(CriterionSupplier supplier) {
try { return supplier.get(); }
catch (Exception e) {
log.debug("Criterion 계산 실패 (무시): {}", e.getMessage());
return 0.0;
}
}
private int safeCalcInt(CriterionSupplier supplier) {
try { return (int) Math.round(supplier.get()); }
catch (Exception e) { return 0; }
}
@FunctionalInterface
interface CriterionSupplier { double get() throws Exception; }
// ── DB 저장 ───────────────────────────────────────────────────────────────
private Long saveResult(BacktestRequest req, BacktestSettingsDto cfg,
List<Signal> signals, BacktestAnalysisDto analysis,
BarSeries series, String strategyName) {
try {
List<OhlcvBar> bars = req.getBars();
long fromTime = bars.isEmpty() ? 0 : bars.get(0).getTime();
long toTime = bars.isEmpty() ? 0 : bars.get(bars.size() - 1).getTime();
GcBacktestResult entity = GcBacktestResult.builder()
.deviceId(req.getDeviceId())
.strategyId(req.getStrategyId())
.strategyName(strategyName)
.symbol(req.getSymbol() != null ? req.getSymbol() : "UNKNOWN")
.timeframe(req.getTimeframe())
.barCount(bars.size())
.fromTime(fromTime)
.toTime(toTime)
.settingsJson(objectMapper.writeValueAsString(cfg))
.signalsJson(objectMapper.writeValueAsString(signals))
.analysisJson(objectMapper.writeValueAsString(analysis))
.totalReturn(BigDecimal.valueOf(analysis.getTotalReturnPct()))
.winRate(BigDecimal.valueOf(analysis.getWinRate()))
.totalTrades(analysis.getNumberOfPositions())
.maxDrawdown(BigDecimal.valueOf(analysis.getMaxDrawdownPct()))
.sharpeRatio(BigDecimal.valueOf(analysis.getSharpeRatio()))
.finalEquity(BigDecimal.valueOf(analysis.getFinalEquity()))
.build();
return resultRepository.save(entity).getId();
} catch (Exception e) {
log.warn("백테스팅 결과 DB 저장 실패: {}", e.getMessage());
return null;
}
}
// ── Stats 변환 (하위 호환) ────────────────────────────────────────────────
private Stats toStats(BacktestAnalysisDto a, List<Signal> signals) {
int buySignals = (int) signals.stream().filter(s -> "BUY".equals(s.getType()) || "SHORT_ENTRY".equals(s.getType())).count();
int sellSignals = (int) signals.stream().filter(s -> "SELL".equals(s.getType()) || "SHORT_EXIT".equals(s.getType())).count();
return Stats.builder()
.totalSignals(signals.size())
.buySignals(buySignals)
.sellSignals(sellSignals)
.totalTrades(a.getNumberOfPositions())
.winTrades(a.getNumberOfWinning())
.winRate(a.getWinRate())
.totalReturn(a.getTotalReturnPct())
.maxDrawdown(a.getMaxDrawdownPct())
.avgReturn(a.getAvgReturnPct())
.finalEquity(a.getFinalEquity())
.build();
}
// ── 가격 결정 ─────────────────────────────────────────────────────────────
private double getPrice(BarSeries series, List<OhlcvBar> bars, int i, String priceType) {
if ("NEXT_OPEN".equals(priceType) && i + 1 < bars.size())
return bars.get(i + 1).getOpen();
if ("OPEN".equals(priceType))
return series.getBar(i).getOpenPrice().doubleValue();
if ("HIGH".equals(priceType))
return series.getBar(i).getHighPrice().doubleValue();
if ("LOW".equals(priceType))
return series.getBar(i).getLowPrice().doubleValue();
return series.getBar(i).getClosePrice().doubleValue();
}
private double applySlippage(double price, BacktestSettingsDto cfg, boolean isBuy) {
double slip = cfg.getSlippageRate() != null ? cfg.getSlippageRate().doubleValue() : 0.0005;
return isBuy ? price * (1 + slip) : price * (1 - slip);
}
private double calcCommissionRate(BacktestSettingsDto cfg) {
if ("ZERO".equals(cfg.getCommissionType())) return 0.0;
return cfg.getCommissionRate() != null ? cfg.getCommissionRate().doubleValue() : 0.0015;
}
// ── BarSeries 빌드 ────────────────────────────────────────────────────────
private BarSeries buildSeries(List<OhlcvBar> bars, String timeframe) {
BarSeries series = new BaseBarSeriesBuilder()
.withNumFactory(org.ta4j.core.num.DoubleNumFactory.getInstance())
.build();
TimeBarBuilderFactory factory = new TimeBarBuilderFactory();
Duration period = timeframeToDuration(timeframe);
for (OhlcvBar b : bars) {
// b.getTime() = 봉 시작 시각. ta4j Bar.endTime = 봉 종료 시각이므로 duration 가산.
Instant endInst = Instant.ofEpochSecond(b.getTime()).plus(period);
factory.createBarBuilder(series)
.timePeriod(period).endTime(endInst)
.openPrice(b.getOpen()).highPrice(b.getHigh())
.lowPrice(b.getLow()).closePrice(b.getClose())
.volume(b.getVolume()).add();
}
return series;
}
private static Duration timeframeToDuration(String tf) {
if (tf == null) return Duration.ofMinutes(1);
return switch (tf) {
case "1m" -> Duration.ofMinutes(1);
case "3m" -> Duration.ofMinutes(3);
case "5m" -> Duration.ofMinutes(5);
case "15m" -> Duration.ofMinutes(15);
case "30m" -> Duration.ofMinutes(30);
case "1h" -> Duration.ofHours(1);
case "4h" -> Duration.ofHours(4);
case "1D" -> Duration.ofDays(1);
case "1W" -> Duration.ofDays(7);
case "1M" -> Duration.ofDays(30);
default -> Duration.ofMinutes(1);
};
}
private BacktestResponse emptyResponse(String reason) {
log.warn("[BacktestingService] 빈 결과: {}", reason);
BacktestAnalysisDto emptyAnalysis = BacktestAnalysisDto.builder().build();
return BacktestResponse.builder()
.signals(List.of())
.stats(Stats.builder().build())
.analysis(emptyAnalysis)
.build();
}
}
@@ -0,0 +1,145 @@
package com.goldenchart.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goldenchart.entity.GcChartSlot;
import com.goldenchart.entity.GcChartWorkspace;
import com.goldenchart.repository.GcChartWorkspaceRepository;
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.List;
import java.util.Map;
/**
* 차트 워크스페이스·슬롯 설정 저장/조회 서비스.
* userId 또는 deviceId 중 하나를 식별자로 사용한다.
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class ChartSettingsService {
private final GcChartWorkspaceRepository workspaceRepo;
private final ObjectMapper objectMapper;
// ── 워크스페이스 조회 ──────────────────────────────────────────────────────
@Transactional(readOnly = true)
public GcChartWorkspace getWorkspace(Long userId, String deviceId) {
return findWorkspace(userId, deviceId).orElse(null);
}
@Transactional(readOnly = true)
public GcChartWorkspace getWorkspaceWithSlots(Long userId, String deviceId) {
if (userId != null) {
return workspaceRepo.findWithSlotsByUserId(userId).orElse(null);
}
return workspaceRepo.findWithSlotsByDeviceId(deviceId).orElse(null);
}
// ── 워크스페이스 저장 (생성 또는 전체 업데이트) ────────────────────────────
public GcChartWorkspace saveWorkspace(Long userId, String deviceId,
String layoutId, String syncOptionsJson) {
GcChartWorkspace ws = findWorkspace(userId, deviceId)
.orElseGet(() -> GcChartWorkspace.builder()
.userId(userId)
.deviceId(deviceId)
.layoutId(layoutId)
.slots(new ArrayList<>())
.build());
ws.setLayoutId(layoutId);
ws.setSyncOptionsJson(syncOptionsJson);
return workspaceRepo.save(ws);
}
// ── 슬롯 설정 저장 ─────────────────────────────────────────────────────────
public GcChartSlot saveSlot(Long userId, String deviceId, int slotIndex,
Map<String, Object> slotData) {
GcChartWorkspace ws = findOrCreateWorkspace(userId, deviceId);
GcChartSlot slot = ws.getSlots().stream()
.filter(s -> s.getSlotIndex() == slotIndex)
.findFirst()
.orElseGet(() -> {
GcChartSlot newSlot = GcChartSlot.builder()
.workspace(ws)
.slotIndex(slotIndex)
.build();
ws.getSlots().add(newSlot);
return newSlot;
});
applySlotData(slot, slotData);
workspaceRepo.save(ws);
return slot;
}
// ── 전체 워크스페이스 + 슬롯 일괄 저장 (프론트 최초 로드 또는 전체 저장) ──
public GcChartWorkspace saveAll(Long userId, String deviceId,
String layoutId, String syncOptionsJson,
List<Map<String, Object>> slotsData) {
GcChartWorkspace ws = findOrCreateWorkspace(userId, deviceId);
ws.setLayoutId(layoutId);
ws.setSyncOptionsJson(syncOptionsJson);
// 슬롯 동기화
List<GcChartSlot> existing = ws.getSlots();
for (int i = 0; i < slotsData.size(); i++) {
final int idx = i;
GcChartSlot slot = existing.stream()
.filter(s -> s.getSlotIndex() == idx)
.findFirst()
.orElseGet(() -> {
GcChartSlot ns = GcChartSlot.builder()
.workspace(ws).slotIndex(idx).build();
existing.add(ns);
return ns;
});
applySlotData(slot, slotsData.get(i));
}
// 초과 슬롯 제거
existing.removeIf(s -> s.getSlotIndex() >= slotsData.size());
return workspaceRepo.save(ws);
}
// ── private helpers ────────────────────────────────────────────────────────
private java.util.Optional<GcChartWorkspace> findWorkspace(Long userId, String deviceId) {
if (userId != null) return workspaceRepo.findByUserId(userId);
return workspaceRepo.findByDeviceId(deviceId);
}
private GcChartWorkspace findOrCreateWorkspace(Long userId, String deviceId) {
return findWorkspace(userId, deviceId)
.orElseGet(() -> workspaceRepo.save(GcChartWorkspace.builder()
.userId(userId).deviceId(deviceId).layoutId("1").build()));
}
private void applySlotData(GcChartSlot slot, Map<String, Object> d) {
if (d.containsKey("symbol")) slot.setSymbol((String) d.get("symbol"));
if (d.containsKey("timeframe")) slot.setTimeframe((String) d.get("timeframe"));
if (d.containsKey("chartType")) slot.setChartType((String) d.get("chartType"));
if (d.containsKey("theme")) slot.setTheme((String) d.get("theme"));
if (d.containsKey("mode")) slot.setMode((String) d.get("mode"));
if (d.containsKey("logScale")) slot.setLogScale(Boolean.parseBoolean(d.get("logScale").toString()));
if (d.containsKey("drawingsLocked")) slot.setDrawingsLocked(Boolean.parseBoolean(d.get("drawingsLocked").toString()));
if (d.containsKey("drawingsVisible"))slot.setDrawingsVisible(Boolean.parseBoolean(d.get("drawingsVisible").toString()));
try {
if (d.containsKey("indicators")) slot.setIndicatorsJson(objectMapper.writeValueAsString(d.get("indicators")));
if (d.containsKey("drawings")) slot.setDrawingsJson(objectMapper.writeValueAsString(d.get("drawings")));
if (d.containsKey("paneLayout")) slot.setPaneLayoutJson(objectMapper.writeValueAsString(d.get("paneLayout")));
if (d.containsKey("mainChartStyle")) slot.setMainChartStyleJson(objectMapper.writeValueAsString(d.get("mainChartStyle")));
} catch (JsonProcessingException e) {
log.error("슬롯 JSON 직렬화 실패", e);
}
}
}
@@ -0,0 +1,91 @@
package com.goldenchart.service;
import com.goldenchart.dto.LiveStrategySettingsDto;
import com.goldenchart.dto.LiveSummaryDto;
import com.goldenchart.dto.PaperSummaryDto;
import com.goldenchart.dto.TradeSignalDto;
import com.goldenchart.entity.GcAppSettings;
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
import com.goldenchart.repository.GcTradeSignalRepository;
import com.goldenchart.repository.GcWatchlistRepository;
import com.goldenchart.trading.pipeline.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class DashboardService {
private final AppSettingsService appSettingsService;
private final PaperTradingService paperTradingService;
private final LiveTradingService liveTradingService;
private final LiveStrategySettingsService liveStrategySettingsService;
private final GcWatchlistRepository watchlistRepo;
private final GcTradeSignalRepository signalRepo;
private final GcLiveStrategySettingsRepository liveSettingsRepo;
private final FcmPushService fcmPushService;
private final PipelineMonitorService pipelineMonitorService;
@Transactional(readOnly = true)
public Map<String, Object> buildSummary(Long userId, String deviceId) {
Map<String, Object> out = new HashMap<>();
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
out.put("app", Map.of(
"tradingMode", app.getTradingMode() != null ? app.getTradingMode() : "PAPER",
"liveAutoTradeEnabled", Boolean.TRUE.equals(app.getLiveAutoTradeEnabled()),
"paperAutoTradeEnabled", Boolean.TRUE.equals(app.getPaperAutoTradeEnabled()),
"liveStrategyCheck", Boolean.TRUE.equals(app.getLiveStrategyCheck()),
"chartRealtimeSource", app.getChartRealtimeSource() != null ? app.getChartRealtimeSource() : "BACKEND_STOMP",
"fcmPushEnabled", Boolean.TRUE.equals(app.getFcmPushEnabled()),
"hasUpbitKeys", liveTradingService.hasApiKeys(app)
));
int watchlistCount = userId != null
? watchlistRepo.findByUserIdOrderByDisplayOrderAsc(userId).size()
: watchlistRepo.findByDeviceIdOrderByDisplayOrderAsc(deviceId).size();
out.put("watchlistCount", watchlistCount);
int liveCheckMarkets = (int) liveSettingsRepo.findAllByIsLiveCheckTrue().stream()
.filter(s -> deviceId.equals(s.getDeviceId()) || (userId != null && userId.equals(s.getUserId())))
.count();
out.put("liveCheckMarkets", liveCheckMarkets);
List<LiveStrategySettingsDto> active = liveStrategySettingsService.listActive(userId, deviceId);
out.put("monitoredMarkets", active.size());
PaperSummaryDto paper = paperTradingService.getSummary(userId, deviceId, null);
out.put("paper", paper);
LiveSummaryDto live = liveTradingService.getSummary(userId, deviceId);
out.put("live", live);
List<TradeSignalDto> recentSignals = signalRepo
.findByDeviceIdOrderByCreatedAtDesc(deviceId)
.stream().limit(8)
.map(s -> TradeSignalDto.builder()
.id(s.getId())
.market(s.getMarket())
.signalType(s.getSignalType())
.price(s.getPrice())
.candleType(s.getCandleType())
.createdAt(s.getCreatedAt() != null ? s.getCreatedAt().toString() : null)
.build())
.toList();
out.put("recentSignals", recentSignals);
out.put("systemMonitor", pipelineMonitorService.buildMonitorSnapshot());
out.put("fcm", Map.of(
"available", fcmPushService.isAvailable(),
"pushEnabled", Boolean.TRUE.equals(app.getFcmPushEnabled())
));
return out;
}
}
@@ -0,0 +1,133 @@
package com.goldenchart.service;
import com.goldenchart.entity.GcFcmToken;
import com.goldenchart.repository.GcFcmTokenRepository;
import com.google.firebase.FirebaseApp;
import com.google.firebase.messaging.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Slf4j
public class FcmPushService {
private final GcFcmTokenRepository tokenRepo;
private final AppSettingsService appSettingsService;
@Value("${firebase.enabled:false}")
private boolean firebaseEnabled;
@Transactional
public void registerToken(Long userId, String deviceId, String token) {
if (token == null || token.isBlank()) return;
String dev = deviceId != null ? deviceId : "anonymous";
Optional<GcFcmToken> existing = tokenRepo.findByToken(token);
if (existing.isPresent()) {
GcFcmToken t = existing.get();
t.setUserId(userId);
t.setDeviceId(dev);
t.setActive(true);
t.setLastUsedAt(LocalDateTime.now());
tokenRepo.save(t);
} else {
tokenRepo.save(GcFcmToken.builder()
.token(token)
.userId(userId)
.deviceId(dev)
.active(true)
.lastUsedAt(LocalDateTime.now())
.build());
}
log.info("[FCM] token registered device={}", dev);
}
@Transactional
public void deleteByDevice(String deviceId) {
if (deviceId != null) tokenRepo.deleteByDeviceId(deviceId);
}
public boolean isAvailable() {
return firebaseEnabled && !FirebaseApp.getApps().isEmpty();
}
/**
* 매매 시그널 FCM — deviceId 일치 토큰 + fcm_push_enabled 설정 시.
*/
public void sendTradeSignalIfEnabled(String deviceId, Long userId, String market,
String signalType, double price,
String strategyName, Long signalId) {
if (!isAvailable() || deviceId == null) return;
var app = appSettingsService.getEntity(userId, deviceId);
if (!Boolean.TRUE.equals(app.getFcmPushEnabled())) return;
List<GcFcmToken> tokens = tokenRepo.findByDeviceIdAndActiveTrue(deviceId);
if (tokens.isEmpty()) {
tokens = tokenRepo.findByActiveTrue();
}
if (tokens.isEmpty()) return;
String title = "BUY".equals(signalType) ? "🟢 매수 시그널" : "🔴 매도 시그널";
String body = String.format("%s · ₩%,.0f%s",
market, price,
strategyName != null ? " · " + strategyName : "");
for (GcFcmToken t : tokens) {
try {
Message msg = Message.builder()
.setToken(t.getToken())
.setNotification(Notification.builder().setTitle(title).setBody(body).build())
.putData("type", "trade_signal")
.putData("signalId", signalId != null ? String.valueOf(signalId) : "")
.putData("market", market)
.putData("signalType", signalType)
.putData("price", String.valueOf(price))
.build();
FirebaseMessaging.getInstance().send(msg);
t.setLastUsedAt(LocalDateTime.now());
tokenRepo.save(t);
} catch (FirebaseMessagingException e) {
log.warn("[FCM] send failed: {}", e.getMessagingErrorCode());
if (e.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED
|| e.getMessagingErrorCode() == MessagingErrorCode.INVALID_ARGUMENT) {
t.setActive(false);
tokenRepo.save(t);
}
} catch (Exception e) {
log.warn("[FCM] send error: {}", e.getMessage());
}
}
}
public void sendTest(Long userId, String deviceId) {
if (!isAvailable()) {
log.warn("[FCM] test skipped — Firebase not initialized");
return;
}
List<GcFcmToken> tokens = deviceId != null
? tokenRepo.findByDeviceIdAndActiveTrue(deviceId)
: tokenRepo.findByActiveTrue();
for (GcFcmToken t : tokens) {
try {
Message msg = Message.builder()
.setToken(t.getToken())
.setNotification(Notification.builder()
.setTitle("GoldenChart 테스트")
.setBody("FCM 푸시가 정상 동작합니다.")
.build())
.putData("type", "test")
.build();
FirebaseMessaging.getInstance().send(msg);
} catch (Exception e) {
log.warn("[FCM] test send failed: {}", e.getMessage());
}
}
}
}
@@ -0,0 +1,314 @@
package com.goldenchart.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goldenchart.dto.CandleBarDto;
import com.goldenchart.storage.Ta4jStorage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import org.ta4j.core.*;
import org.ta4j.core.bars.TimeBarBuilderFactory;
import org.ta4j.core.indicators.RSIIndicator;
import org.ta4j.core.indicators.averages.EMAIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.num.DoubleNumFactory;
import java.time.Duration;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* 과거 캔들 데이터 페이징 중계기 (명세서 3.4).
*
* 동작 메커니즘:
* 1. 요청한 기준 시간(to) 이 Ta4jStorage 인메모리(최근 300개) 범위 내 → 메모리 슬라이싱 반환
* 2. 인메모리 범위 초과(먼 과거) → 업비트 REST API 포워딩
* 3. 응답 데이터 + 워밍업 캔들을 임시 BarSeries 에 로드 → Ta4j RSI 계산 후 첨부 반환
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class HistoricalDataService {
private final Ta4jStorage ta4jStorage;
private final WebClient.Builder webClientBuilder;
private final ObjectMapper objectMapper;
@Value("${upbit.api.base-url:https://api.upbit.com}")
private String upbitBaseUrl;
@Value("${upbit.api.candle-limit:200}")
private int candleLimit;
@Value("${upbit.api.request-delay-ms:110}")
private long requestDelayMs;
/** RSI 계산 기본 기간 */
private static final int RSI_PERIOD = 14;
/** RSI 수렴에 필요한 워밍업 캔들 수 (실제 반환하지 않음) */
private static final int RSI_WARMUP = 50;
// ── 공개 API ──────────────────────────────────────────────────────────────
/**
* 과거 캔들 조회.
*
* @param market 업비트 마켓 코드 (e.g. "KRW-BTC")
* @param candleType 캔들 타입 (e.g. "1m", "1d")
* @param to 기준 시간 이전(exclusive) ISO-8601 문자열 (e.g. "2026-05-20T10:00:00")
* @param count 요청 캔들 수 (최대 200)
* @return CandleBarDto 리스트 (시간 오름차순)
*/
public List<CandleBarDto> getHistory(String market, String candleType, String to, int count) {
int safeCount = Math.min(count, candleLimit);
// ── 1. to 시간 파싱 ───────────────────────────────────────────────────
ZonedDateTime toTime = parseToTime(to);
// ── 2. 인메모리 → 부족 시 업비트 REST 폴백 ─────────────────────────────
List<UpbitCandleRaw> rawBars = Collections.emptyList();
Optional<ZonedDateTime> oldestMemory = ta4jStorage.getOldestTime(market, candleType);
boolean inMemory = oldestMemory.isPresent() && !toTime.isBefore(oldestMemory.get());
if (inMemory) {
log.debug("[HistoricalDataService] 인메모리에서 조회: {} / {} / to={}", market, candleType, to);
rawBars = sliceFromMemory(market, candleType, toTime, safeCount);
}
if (rawBars.isEmpty()) {
log.debug("[HistoricalDataService] 업비트 REST 프록시: {} / {} / to={}", market, candleType, to);
rawBars = fetchFromUpbit(market, candleType, to, safeCount);
}
if (rawBars.isEmpty()) return Collections.emptyList();
// ── 3. RSI 계산 (실패 시 OHLCV만 반환) ─────────────────────────────────
return attachRsi(rawBars, market, candleType);
}
// ── 인메모리 슬라이싱 ─────────────────────────────────────────────────────
private List<UpbitCandleRaw> sliceFromMemory(String market, String candleType,
ZonedDateTime toTime, int count) {
List<Bar> bars = ta4jStorage.getBars(market, candleType);
Instant toInstant = toTime.toInstant();
// toTime 이전 Bar 만 필터링, 최신부터 count 개 역순 선택 후 다시 시간순 정렬
List<UpbitCandleRaw> result = bars.stream()
.filter(b -> !b.getEndTime().isAfter(toInstant))
.sorted(Comparator.comparing(Bar::getEndTime).reversed())
.limit(count)
.map(UpbitCandleRaw::of)
.collect(Collectors.toList());
Collections.reverse(result);
return result;
}
// ── 업비트 REST 프록시 ────────────────────────────────────────────────────
private List<UpbitCandleRaw> fetchFromUpbit(String market, String candleType,
String to, int count) {
String path = Ta4jStorage.CANDLE_TYPE_MAP.getOrDefault(candleType, "minutes/1");
String url = upbitBaseUrl + "/v1/candles/" + path
+ "?market=" + market
+ "&count=" + count
+ (to != null && !to.isBlank() ? "&to=" + to + "Z" : "");
try {
Thread.sleep(requestDelayMs); // 업비트 요청 속도 제한 준수
String json = webClientBuilder.build()
.get()
.uri(url)
.retrieve()
.bodyToMono(String.class)
.block();
if (json == null || json.isBlank()) return Collections.emptyList();
List<Map<String, Object>> raw =
objectMapper.readValue(json, new TypeReference<>() {});
// 업비트 응답은 최신→과거 순 → 시간 오름차순으로 뒤집기
List<UpbitCandleRaw> list = raw.stream()
.map(this::parseUpbitCandle)
.sorted(Comparator.comparingLong(c -> c.time))
.collect(Collectors.toList());
return list;
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
log.error("[HistoricalDataService] 업비트 요청 인터럽트", ie);
} catch (Exception e) {
log.error("[HistoricalDataService] 업비트 REST 호출 실패: {}", e.getMessage());
}
return Collections.emptyList();
}
private UpbitCandleRaw parseUpbitCandle(Map<String, Object> m) {
// candle_date_time_utc: "2026-05-20T10:00:00"
String dtStr = (String) m.get("candle_date_time_utc");
long epochSec = ZonedDateTime.parse(dtStr + "Z",
java.time.format.DateTimeFormatter.ISO_DATE_TIME).toEpochSecond();
return new UpbitCandleRaw(
epochSec,
toDouble(m.get("opening_price")),
toDouble(m.get("high_price")),
toDouble(m.get("low_price")),
toDouble(m.get("trade_price")),
toDouble(m.get("candle_acc_trade_volume"))
);
}
// ── RSI 계산 (Ta4j 임시 BarSeries) ────────────────────────────────────────
/**
* rawBars 앞에 워밍업용 캔들을 추가(메모리에서 보충)하여 임시 BarSeries 구성 후
* RSI 를 계산. 워밍업 구간은 결과에서 제외하고 원래 rawBars 구간만 반환.
*/
private List<CandleBarDto> attachRsi(List<UpbitCandleRaw> rawBars,
String market, String candleType) {
// 워밍업 캔들: 인메모리에서 rawBars 의 가장 오래된 시간 이전 데이터를 추가
List<UpbitCandleRaw> warmup = getWarmupBars(market, candleType,
Ta4jStorage.epochToZdt(rawBars.get(0).time), RSI_WARMUP);
// 임시 BarSeries 구성 (워밍업 + 대상 bars)
BarSeries tmpSeries = buildTempSeries(warmup, rawBars, candleType);
if (tmpSeries.isEmpty()) {
return rawBars.stream()
.map(HistoricalDataService::toDtoWithoutRsi)
.collect(Collectors.toList());
}
ClosePriceIndicator close = new ClosePriceIndicator(tmpSeries);
RSIIndicator rsi = new RSIIndicator(close, RSI_PERIOD);
int warmupSize = warmup.size();
int endIdx = tmpSeries.getEndIndex();
List<CandleBarDto> result = new ArrayList<>();
for (int i = 0; i < rawBars.size(); i++) {
int seriesIdx = warmupSize + i;
UpbitCandleRaw raw = rawBars.get(i);
Double rsiVal = null;
if (seriesIdx >= RSI_PERIOD && seriesIdx <= endIdx) {
try {
double v = rsi.getValue(seriesIdx).doubleValue();
if (!Double.isNaN(v) && !Double.isInfinite(v)) rsiVal = v;
} catch (Exception e) {
log.trace("[HistoricalDataService] RSI 스킵 idx={}: {}", seriesIdx, e.getMessage());
}
}
result.add(CandleBarDto.builder()
.time(raw.time)
.open(raw.open)
.high(raw.high)
.low(raw.low)
.close(raw.close)
.volume(raw.volume)
.rsi(rsiVal)
.build());
}
return result;
}
private static CandleBarDto toDtoWithoutRsi(UpbitCandleRaw raw) {
return CandleBarDto.builder()
.time(raw.time)
.open(raw.open)
.high(raw.high)
.low(raw.low)
.close(raw.close)
.volume(raw.volume)
.build();
}
/** 인메모리에서 워밍업 캔들 추출 */
private List<UpbitCandleRaw> getWarmupBars(String market, String candleType,
ZonedDateTime before, int maxCount) {
if (!ta4jStorage.exists(market, candleType)) return Collections.emptyList();
List<Bar> bars = ta4jStorage.getBars(market, candleType);
Instant beforeInstant = before.toInstant();
return bars.stream()
.filter(b -> b.getEndTime().isBefore(beforeInstant))
.sorted(Comparator.comparing(Bar::getEndTime).reversed())
.limit(maxCount)
.map(UpbitCandleRaw::of)
.sorted(Comparator.comparingLong(c -> c.time))
.collect(Collectors.toList());
}
/** 워밍업 + 대상 캔들 배열로 임시 BarSeries 생성 */
private BarSeries buildTempSeries(List<UpbitCandleRaw> warmup,
List<UpbitCandleRaw> target,
String candleType) {
BarSeries series = new BaseBarSeriesBuilder()
.withName("tmp")
.withBarBuilderFactory(new TimeBarBuilderFactory())
.withNumFactory(DoubleNumFactory.getInstance()) // Ta4j 0.22
.build();
Duration duration = Ta4jStorage.parseDuration(candleType);
for (UpbitCandleRaw r : warmup) addRawToSeries(series, r, duration);
for (UpbitCandleRaw r : target) addRawToSeries(series, r, duration);
return series;
}
private void addRawToSeries(BarSeries series, UpbitCandleRaw r, Duration duration) {
try {
// Ta4j 0.22: barBuilder().endTime() 은 Instant 파라미터
Instant endInstant = Ta4jStorage.epochToInstant(r.time).plus(duration);
Bar bar = series.barBuilder()
.timePeriod(duration)
.endTime(endInstant)
.openPrice(r.open)
.highPrice(r.high)
.lowPrice(r.low)
.closePrice(r.close)
.volume(r.volume)
.build();
series.addBar(bar, true);
} catch (Exception e) {
log.trace("[HistoricalDataService] Bar 추가 스킵: {}", e.getMessage());
}
}
// ── 유틸 ──────────────────────────────────────────────────────────────────
private ZonedDateTime parseToTime(String to) {
if (to == null || to.isBlank()) return ZonedDateTime.now(java.time.ZoneOffset.UTC);
try {
// ISO-8601 without Z 또는 with Z 모두 처리
String s = to.endsWith("Z") ? to : to + "Z";
return ZonedDateTime.parse(s, java.time.format.DateTimeFormatter.ISO_DATE_TIME);
} catch (Exception e) {
return ZonedDateTime.now(java.time.ZoneOffset.UTC);
}
}
private static double toDouble(Object v) {
if (v == null) return 0.0;
return ((Number) v).doubleValue();
}
// ── 내부 레코드 ───────────────────────────────────────────────────────────
/** 업비트 캔들 / 인메모리 Bar 공통 내부 표현 */
record UpbitCandleRaw(long time, double open, double high, double low,
double close, double volume) {
static UpbitCandleRaw of(Bar b) {
return new UpbitCandleRaw(
b.getEndTime().getEpochSecond() - b.getTimePeriod().getSeconds(),
b.getOpenPrice().doubleValue(),
b.getHighPrice().doubleValue(),
b.getLowPrice().doubleValue(),
b.getClosePrice().doubleValue(),
b.getVolume().doubleValue()
);
}
}
}
@@ -0,0 +1,818 @@
package com.goldenchart.service;
import com.goldenchart.dto.IndicatorResponse;
import com.goldenchart.dto.IndicatorResponse.PlotPoint;
import com.goldenchart.dto.OhlcvBar;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.ta4j.core.*;
import org.ta4j.core.indicators.*;
import org.ta4j.core.indicators.adx.*;
import org.ta4j.core.indicators.aroon.*;
import org.ta4j.core.indicators.averages.*;
import org.ta4j.core.indicators.bollinger.*;
import org.ta4j.core.indicators.donchian.*;
import org.ta4j.core.indicators.helpers.*;
import org.ta4j.core.indicators.ichimoku.*;
import org.ta4j.core.indicators.keltner.*;
import org.ta4j.core.indicators.numeric.NumericIndicator;
import org.ta4j.core.indicators.numeric.UnaryOperationIndicator;
import org.ta4j.core.indicators.statistics.*;
import org.ta4j.core.indicators.volume.*;
import org.ta4j.core.bars.TimeBarBuilderFactory;
import org.ta4j.core.num.DoubleNumFactory;
import org.ta4j.core.num.Num;
import java.time.Duration;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.stream.IntStream;
/**
* Ta4j 기술 지표 계산 서비스.
* frontend indicatorRegistry.ts 에 등록된 지표를 동일 로직으로 구현.
*/
@Service
@Slf4j
public class IndicatorService {
// ── Public API ────────────────────────────────────────────────────────────
public IndicatorResponse calculate(List<OhlcvBar> bars, String type, Map<String, Object> params, String timeframe) {
BarSeries series = buildSeries(bars, timeframe);
int n = series.getBarCount();
Map<String, Object> p = params != null ? params : Map.of();
Map<String, List<PlotPoint>> result = switch (type) {
// Moving Averages
case "SMA" -> calcSMA(series, p);
case "EMA" -> single("plot0", series, new EMAIndicator(src(series, p), intP(p, "length", 21)));
case "WMA" -> single("plot0", series, new WMAIndicator(src(series, p), intP(p, "length", 20)));
case "HMA" -> single("plot0", series, new HMAIndicator(src(series, p), intP(p, "length", 16)));
case "VWMA" -> single("plot0", series, new VWMAIndicator(new ClosePriceIndicator(series), intP(p, "length", 20)));
case "DEMA" -> single("plot0", series, new DoubleEMAIndicator(src(series, p), intP(p, "length", 21)));
case "TEMA" -> single("plot0", series, new TripleEMAIndicator(src(series, p), intP(p, "length", 21)));
case "RMA", "SMMA" -> single("plot0", series, new WildersMAIndicator(src(series, p), intP(p, "length", 14)));
case "LSMA" -> single("plot0", series, new LSMAIndicator(src(series, p), intP(p, "length", 25)));
case "MACross" -> calcMACross(series, p);
// Bands
case "BollingerBands" -> calcBB(series, p);
case "KeltnerChannels" -> calcKC(series, p);
case "DonchianChannels" -> calcDC(series, p);
case "BBPercentB" -> calcBBPercentB(series, p);
case "BBBandWidth" -> calcBBBandWidth(series, p);
// Oscillators
case "RSI" -> calcRSI(series, p);
case "Stochastic" -> calcStochastic(series, p);
case "StochRSI" -> calcStochRSI(series, p);
case "CCI" -> calcCCI(series, p);
case "WilliamsPercentRange" -> calcWilliamsR(series, p);
case "AwesomeOscillator" -> calcAO(series);
case "DPO" -> single("plot0", series, new DPOIndicator(src(series, p), intP(p, "length", 21)));
// Momentum
case "MACD" -> calcMACD(series, p);
case "Momentum" -> calcMomentumInd(series, p);
case "ROC" -> calcROC(series, p);
case "TSI" -> calcTSI(series, p);
case "TRIX" -> calcTRIX(series, p);
// Trend
case "ADX" -> calcADX(series, p);
case "DMI" -> calcDMI(series, p);
case "Aroon" -> calcAroon(series, p);
case "IchimokuCloud"-> calcIchimoku(series, p);
case "ParabolicSAR" -> calcParabolicSAR(series, p);
case "Choppiness" -> calcChoppiness(series, p);
case "MassIndex" -> calcMassIndex(series, p);
// Volatility
case "ATR" -> single("plot0", series, new ATRIndicator(series, intP(p, "length", 14)));
case "StandardDeviation"-> single("plot0", series, new StandardDeviationIndicator(src(series, p), intP(p, "length", 20)));
case "HistoricalVolatility" -> calcHV(series, p);
// Volume
case "OBV" -> calcOBV(series, p);
case "MFI" -> single("plot0", series, new MoneyFlowIndexIndicator(series, intP(p, "length", 14)));
case "ChaikinMF"-> single("plot0", series, new ChaikinMoneyFlowIndicator(series, intP(p, "length", 20)));
case "VolumeOscillator" -> calcVolumeOscillator(series, p);
case "VR" -> calcVR(series, p);
case "Disparity" -> calcDisparity(series, p);
case "Psychological", "NewPsychological" -> calcPsychological(series, p);
case "InvestPsychological"-> calcInvestPsychological(series, p);
default -> throw new IllegalArgumentException("지원하지 않는 지표: " + type);
};
List<Long> times = IntStream.range(0, n)
.mapToObj(i -> {
Bar bar = series.getBar(i);
return bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
})
.toList();
return IndicatorResponse.builder()
.indicatorType(type)
.times(times)
.values(result)
.build();
}
// ── Series Builder ────────────────────────────────────────────────────────
/**
* OhlcvBar 목록으로 Ta4j BarSeries 를 생성한다.
*
* @param bars OHLCV 바 데이터
* @param timeframe 타임프레임 문자열 (1m/5m/15m/30m/1h/4h/1D/1W/1M).
* null 또는 알 수 없는 값이면 1분으로 fallback.
*/
private BarSeries buildSeries(List<OhlcvBar> bars, String timeframe) {
BarSeries series = new BaseBarSeriesBuilder()
.withNumFactory(DoubleNumFactory.getInstance())
.build();
TimeBarBuilderFactory factory = new TimeBarBuilderFactory();
Duration period = timeframeToDuration(timeframe);
for (OhlcvBar b : bars) {
// b.getTime() 은 봉 시작 시각(Unix 초).
// ta4j Bar 의 endTime 은 봉 종료 시각이므로 duration 을 더해야 한다.
java.time.Instant endInst = java.time.Instant.ofEpochSecond(b.getTime()).plus(period);
factory.createBarBuilder(series)
.timePeriod(period)
.endTime(endInst)
.openPrice(b.getOpen())
.highPrice(b.getHigh())
.lowPrice(b.getLow())
.closePrice(b.getClose())
.volume(b.getVolume())
.add();
}
return series;
}
/**
* 타임프레임 문자열을 Duration 으로 변환.
* frontend Timeframe 타입 (1m/5m/15m/30m/1h/4h/1D/1W/1M)과 1:1 대응.
*/
private static Duration timeframeToDuration(String tf) {
if (tf == null) return Duration.ofMinutes(1);
return switch (tf) {
case "1m" -> Duration.ofMinutes(1);
case "3m" -> Duration.ofMinutes(3);
case "5m" -> Duration.ofMinutes(5);
case "15m" -> Duration.ofMinutes(15);
case "30m" -> Duration.ofMinutes(30);
case "1h" -> Duration.ofHours(1);
case "4h" -> Duration.ofHours(4);
case "1D" -> Duration.ofDays(1);
case "1W" -> Duration.ofDays(7);
case "1M" -> Duration.ofDays(30);
default -> Duration.ofMinutes(1);
};
}
// ── Helpers ───────────────────────────────────────────────────────────────
private int intP(Map<String, Object> p, String k, int def) {
Object v = p.get(k); return v == null ? def : ((Number) v).intValue();
}
private double dblP(Map<String, Object> p, String k, double def) {
Object v = p.get(k); return v == null ? def : ((Number) v).doubleValue();
}
private Indicator<Num> src(BarSeries s, Map<String, Object> p) {
return switch (p.getOrDefault("src", "close").toString()) {
case "open" -> new OpenPriceIndicator(s);
case "high" -> new HighPriceIndicator(s);
case "low" -> new LowPriceIndicator(s);
case "hl2" -> new MedianPriceIndicator(s);
case "hlc3" -> new TypicalPriceIndicator(s);
default -> new ClosePriceIndicator(s);
};
}
private Double safe(Num n) {
if (n == null) return null;
double v = n.doubleValue();
return Double.isNaN(v) || Double.isInfinite(v) ? null : v;
}
private List<PlotPoint> toPlot(BarSeries s, Indicator<Num> ind) {
int n = s.getBarCount();
List<PlotPoint> pts = new ArrayList<>(n);
for (int i = 0; i < n; i++) {
Bar bar = s.getBar(i);
// 프론트 차트의 time 축은 봉 시작 시각 기준.
// endTime - timePeriod = 봉 시작 시각
long startEpoch = bar.getEndTime().getEpochSecond()
- bar.getTimePeriod().getSeconds();
pts.add(PlotPoint.builder()
.time(startEpoch)
.value(safe(ind.getValue(i)))
.build());
}
return pts;
}
private Map<String, List<PlotPoint>> single(String id, BarSeries s, Indicator<Num> ind) {
return Map.of(id, toPlot(s, ind));
}
// ── Moving Averages ───────────────────────────────────────────────────────
/** MA1~MA11 기본 기간: 5, 12, 16, 20, 26, 34, 50, 60, 72, 90, 120 */
private static final int[] SMA_DEFAULT_PERIODS = { 5, 12, 16, 20, 26, 34, 50, 60, 72, 90, 120 };
private Map<String, List<PlotPoint>> calcSMA(BarSeries s, Map<String, Object> p) {
Indicator<Num> source = src(s, p);
Map<String, List<PlotPoint>> result = new LinkedHashMap<>();
for (int i = 0; i < SMA_DEFAULT_PERIODS.length; i++) {
String periodKey = "period" + (i + 1);
int period = intP(p, periodKey, SMA_DEFAULT_PERIODS[i]);
// 레거시 단일 len → MA1
if (i == 0 && p.containsKey("len")) {
period = intP(p, "len", SMA_DEFAULT_PERIODS[0]);
}
period = Math.max(1, period);
result.put("plot" + i, toPlot(s, new SMAIndicator(source, period)));
}
return result;
}
private Map<String, List<PlotPoint>> calcMACross(BarSeries s, Map<String, Object> p) {
Indicator<Num> src = src(s, p);
return Map.of(
"plot0", toPlot(s, new EMAIndicator(src, intP(p, "fastLength", 9))),
"plot1", toPlot(s, new EMAIndicator(src, intP(p, "slowLength", 21)))
);
}
// ── Bands ─────────────────────────────────────────────────────────────────
private Map<String, List<PlotPoint>> calcBB(BarSeries s, Map<String, Object> p) {
int len = intP(p, "length", 20);
double mult = dblP(p, "mult", 2.0);
int offset = intP(p, "offset", 0);
// 업비트 BB: 종가 SMA + 모집단 표준편차 × 곱
ClosePriceIndicator close = new ClosePriceIndicator(s);
SMAIndicator basis = new SMAIndicator(close, len);
StandardDeviationIndicator std = StandardDeviationIndicator.ofPopulation(close, len);
BollingerBandsMiddleIndicator mid = new BollingerBandsMiddleIndicator(basis);
Num k = s.numFactory().numOf(mult);
BollingerBandsUpperIndicator upper = new BollingerBandsUpperIndicator(mid, std, k);
BollingerBandsLowerIndicator lower = new BollingerBandsLowerIndicator(mid, std, k);
return Map.of(
"plot0", shiftPlotOffset(toPlot(s, mid), offset),
"plot1", shiftPlotOffset(toPlot(s, upper), offset),
"plot2", shiftPlotOffset(toPlot(s, lower), offset)
);
}
/** TradingView/업비트 오프셋: 양수 = 플롯을 시간축 오른쪽으로 이동 */
private List<PlotPoint> shiftPlotOffset(List<PlotPoint> pts, int offset) {
if (offset == 0) return pts;
int n = pts.size();
List<PlotPoint> out = new ArrayList<>(n);
for (int i = 0; i < n; i++) {
int src = i - offset;
Double val = (src >= 0 && src < n) ? pts.get(src).getValue() : null;
out.add(PlotPoint.builder().time(pts.get(i).getTime()).value(val).build());
}
return out;
}
private Indicator<Num> maOf(Indicator<Num> source, String maType, int len, BarSeries s) {
return switch (maType) {
case "EMA" -> new EMAIndicator(source, len);
case "WMA" -> new WMAIndicator(source, len);
case "SMMA (RMA)", "RMA" -> new WildersMAIndicator(source, len);
case "VWMA" -> new VWMAIndicator(source, len);
default -> new SMAIndicator(source, len);
};
}
private Map<String, List<PlotPoint>> calcKC(BarSeries s, Map<String, Object> p) {
int len = intP(p, "length", 20);
double mult = dblP(p, "multiplier", 2.0);
EMAIndicator ema = new EMAIndicator(new ClosePriceIndicator(s), len);
KeltnerChannelMiddleIndicator mid = new KeltnerChannelMiddleIndicator(s, len);
ATRIndicator atr = new ATRIndicator(s, len);
KeltnerChannelUpperIndicator upper = new KeltnerChannelUpperIndicator(mid, atr, mult);
KeltnerChannelLowerIndicator lower = new KeltnerChannelLowerIndicator(mid, atr, (double) mult);
return Map.of(
"plot0", toPlot(s, upper),
"plot1", toPlot(s, ema),
"plot2", toPlot(s, lower)
);
}
private Map<String, List<PlotPoint>> calcDC(BarSeries s, Map<String, Object> p) {
int len = intP(p, "length", 20);
DonchianChannelUpperIndicator upper = new DonchianChannelUpperIndicator(s, len);
DonchianChannelLowerIndicator lower = new DonchianChannelLowerIndicator(s, len);
// basis = (upper + lower) / 2
NumericIndicator basis = NumericIndicator.of(upper).plus(lower).dividedBy(2);
return Map.of(
"plot0", toPlot(s, upper),
"plot1", toPlot(s, basis),
"plot2", toPlot(s, lower)
);
}
private Map<String, List<PlotPoint>> calcBBPercentB(BarSeries s, Map<String, Object> p) {
int len = intP(p, "length", 20);
double mult = dblP(p, "mult", 2.0);
Indicator<Num> src = src(s, p);
SMAIndicator sma = new SMAIndicator(src, len);
StandardDeviationIndicator std = StandardDeviationIndicator.ofSample(src, len);
BollingerBandsMiddleIndicator mid = new BollingerBandsMiddleIndicator(sma);
Num k = s.numFactory().numOf(mult);
BollingerBandsUpperIndicator upper = new BollingerBandsUpperIndicator(mid, std, k);
BollingerBandsLowerIndicator lower = new BollingerBandsLowerIndicator(mid, std, k);
// %B = (price - lower) / (upper - lower)
NumericIndicator pctB = NumericIndicator.of(src)
.minus(lower)
.dividedBy(NumericIndicator.of(upper).minus(lower));
return single("plot0", s, pctB);
}
private Map<String, List<PlotPoint>> calcBBBandWidth(BarSeries s, Map<String, Object> p) {
int len = intP(p, "length", 20);
double mult = dblP(p, "mult", 2.0);
Indicator<Num> src = src(s, p);
SMAIndicator sma = new SMAIndicator(src, len);
StandardDeviationIndicator std = StandardDeviationIndicator.ofSample(src, len);
BollingerBandsMiddleIndicator mid = new BollingerBandsMiddleIndicator(sma);
Num k = s.numFactory().numOf(mult);
BollingerBandsUpperIndicator upper = new BollingerBandsUpperIndicator(mid, std, k);
BollingerBandsLowerIndicator lower = new BollingerBandsLowerIndicator(mid, std, k);
BollingerBandWidthIndicator bw = new BollingerBandWidthIndicator(upper, mid, lower);
return single("plot0", s, bw);
}
// ── Oscillators ───────────────────────────────────────────────────────────
/**
* RSI + 선택적 신호선 (SMA / EMA / WMA).
* maType == "None" 이면 plot1 을 null 로 채워 비활성 처리.
*/
private Map<String, List<PlotPoint>> calcRSI(BarSeries s, Map<String, Object> p) {
int len = intP(p, "length", 14);
int maLen = intP(p, "maLength", 14);
String maType = p.getOrDefault("maType", "SMA").toString();
RSIIndicator rsi = new RSIIndicator(src(s, p), len);
Indicator<Num> maInd = switch (maType) {
case "EMA" -> new EMAIndicator(rsi, maLen);
case "WMA" -> new WMAIndicator(rsi, maLen);
case "None" -> null;
default -> new SMAIndicator(rsi, maLen);
};
if (maInd == null) {
int n = s.getBarCount();
List<PlotPoint> empty = new ArrayList<>(n);
for (int i = 0; i < n; i++) {
Bar bar = s.getBar(i);
long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
empty.add(PlotPoint.builder().time(t).value(null).build());
}
return Map.of("plot0", toPlot(s, rsi), "plot1", empty);
}
return Map.of("plot0", toPlot(s, rsi), "plot1", toPlot(s, maInd));
}
/**
* CCI + 선택적 MA 오버레이 (업비트 동일: SMA / EMA / WMA).
* maType == "None" 이면 MA 라인(plot1) 값을 null 로 채워 빈 시리즈로 반환.
*/
private Map<String, List<PlotPoint>> calcCCI(BarSeries s, Map<String, Object> p) {
int len = intP(p, "length", 13);
int maLen = intP(p, "maLength", 20);
String maType = p.getOrDefault("maType", "SMA").toString();
CCIIndicator cci = new CCIIndicator(s, len);
Indicator<Num> maInd = switch (maType) {
case "EMA" -> new EMAIndicator(cci, maLen);
case "WMA" -> new WMAIndicator(cci, maLen);
case "None" -> null;
default -> new SMAIndicator(cci, maLen); // SMA
};
if (maInd == null) {
// MA 비활성 — plot1 을 null 값으로 채워 반환
int n = s.getBarCount();
List<PlotPoint> empty = new ArrayList<>(n);
for (int i = 0; i < n; i++) {
Bar bar = s.getBar(i);
long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
empty.add(PlotPoint.builder().time(t).value(null).build());
}
return Map.of("plot0", toPlot(s, cci), "plot1", empty);
}
return Map.of("plot0", toPlot(s, cci), "plot1", toPlot(s, maInd));
}
private Map<String, List<PlotPoint>> calcStochastic(BarSeries s, Map<String, Object> p) {
int kLen = intP(p, "kLength", 14);
int smooth = intP(p, "smooth", 3);
int dSmooth = intP(p, "dSmoothing", 3);
StochasticOscillatorKIndicator k = new StochasticOscillatorKIndicator(s, kLen);
SMAIndicator kSmooth = new SMAIndicator(k, smooth);
SMAIndicator d = new SMAIndicator(kSmooth, dSmooth);
return Map.of("plot0", toPlot(s, kSmooth), "plot1", toPlot(s, d));
}
private Map<String, List<PlotPoint>> calcStochRSI(BarSeries s, Map<String, Object> p) {
int rsiLen = intP(p, "lengthRSI", 14);
int stochLen = intP(p, "lengthStoch", 14);
int smoothK = intP(p, "smoothK", 3);
int smoothD = intP(p, "smoothD", 3);
RSIIndicator rsi = new RSIIndicator(src(s, p), rsiLen);
StochasticRSIIndicator stochRsi = new StochasticRSIIndicator(rsi, stochLen);
SMAIndicator k = new SMAIndicator(stochRsi, smoothK);
SMAIndicator d = new SMAIndicator(k, smoothD);
return Map.of("plot0", toPlot(s, k), "plot1", toPlot(s, d));
}
private Map<String, List<PlotPoint>> calcAO(BarSeries s) {
// AO = SMA(median,5) - SMA(median,34)
MedianPriceIndicator median = new MedianPriceIndicator(s);
NumericIndicator ao = NumericIndicator.of(new SMAIndicator(median, 5))
.minus(new SMAIndicator(median, 34));
return single("plot0", s, ao);
}
// ── Momentum ──────────────────────────────────────────────────────────────
private Map<String, List<PlotPoint>> calcMACD(BarSeries s, Map<String, Object> p) {
int fast = intP(p, "fastLength", 12);
int slow = intP(p, "slowLength", 26);
int signal = intP(p, "signalLength", 9);
Indicator<Num> src = src(s, p);
MACDIndicator macd = new MACDIndicator(src, fast, slow);
EMAIndicator sigLine = new EMAIndicator(macd, signal);
NumericIndicator histogram = NumericIndicator.of(macd).minus(sigLine);
return Map.of(
"plot0", toPlot(s, histogram),
"plot1", toPlot(s, macd),
"plot2", toPlot(s, sigLine)
);
}
private Map<String, List<PlotPoint>> calcMomentumInd(BarSeries s, Map<String, Object> p) {
int len = intP(p, "length", 10);
return single("plot0", s, new ROCIndicator(src(s, p), len));
}
private Map<String, List<PlotPoint>> calcROC(BarSeries s, Map<String, Object> p) {
int len = intP(p, "length", 9);
NumericIndicator rocPct = NumericIndicator.of(new ROCIndicator(src(s, p), len))
.multipliedBy(100);
return single("plot0", s, rocPct);
}
/**
* TSI (True Strength Index) — 수동 구현 (Ta4j에 없음).
* TSI = 100 * EMA(EMA(diff, longLen), shortLen) / EMA(EMA(absDiff, longLen), shortLen)
*/
private Map<String, List<PlotPoint>> calcTSI(BarSeries s, Map<String, Object> p) {
int longLen = intP(p, "longLength", 25);
int shortLen = intP(p, "shortLength", 13);
int sigLen = intP(p, "signalLength", 13);
int n = s.getBarCount();
ClosePriceIndicator close = new ClosePriceIndicator(s);
// momentum = close - close[1]
List<PlotPoint> tsiPts = new ArrayList<>(n);
List<PlotPoint> sigPts = new ArrayList<>(n);
double[] diff = new double[n];
double[] absDiff = new double[n];
for (int i = 0; i < n; i++) {
if (i == 0) { diff[i] = 0; absDiff[i] = 0; }
else {
Double c1 = safe(close.getValue(i));
Double c0 = safe(close.getValue(i - 1));
diff[i] = (c1 != null && c0 != null) ? c1 - c0 : 0;
absDiff[i] = Math.abs(diff[i]);
}
}
double[] ema1d = ema(diff, longLen, n);
double[] ema2d = ema(ema1d, shortLen, n);
double[] ema1a = ema(absDiff, longLen, n);
double[] ema2a = ema(ema1a, shortLen, n);
double[] tsi = new double[n];
for (int i = 0; i < n; i++) {
tsi[i] = ema2a[i] == 0 ? 0 : 100.0 * ema2d[i] / ema2a[i];
}
double[] sig = ema(tsi, sigLen, n);
for (int i = 0; i < n; i++) {
Bar bar = s.getBar(i);
long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
tsiPts.add(new PlotPoint(t, tsi[i], null));
sigPts.add(new PlotPoint(t, sig[i], null));
}
return Map.of("plot0", tsiPts, "plot1", sigPts);
}
// ── Trend ─────────────────────────────────────────────────────────────────
private Map<String, List<PlotPoint>> calcADX(BarSeries s, Map<String, Object> p) {
int adxSmoothing = intP(p, "adxSmoothing", 14);
int diLen = intP(p, "diLength", 14);
return Map.of(
"plot0", toPlot(s, new ADXIndicator(s, diLen, adxSmoothing)),
"plot1", toPlot(s, new PlusDIIndicator(s, diLen)),
"plot2", toPlot(s, new MinusDIIndicator(s, diLen))
);
}
private Map<String, List<PlotPoint>> calcDMI(BarSeries s, Map<String, Object> p) {
int diLen = intP(p, "diLength", intP(p, "length", 14));
int adxSmooth = intP(p, "adxSmoothing", 14);
PlusDIIndicator plusDI = new PlusDIIndicator(s, diLen);
MinusDIIndicator minusDI = new MinusDIIndicator(s, diLen);
ADXIndicator adx = new ADXIndicator(s, diLen, adxSmooth);
NumericIndicator dx = NumericIndicator.of(plusDI).minus(minusDI).abs()
.dividedBy(NumericIndicator.of(plusDI).plus(minusDI))
.multipliedBy(100);
List<PlotPoint> adxPlot = toPlot(s, adx);
return Map.of(
"plot0", toPlot(s, plusDI),
"plot1", toPlot(s, minusDI),
"plot2", toPlot(s, dx),
"plot3", adxPlot,
"plot4", calcAdxrFromAdx(adxPlot, adxSmooth)
);
}
/** ADXR = (ADX + ADX[n]) / 2 — Wilder·업비트 */
private List<PlotPoint> calcAdxrFromAdx(List<PlotPoint> adxPlot, int lag) {
List<PlotPoint> out = new ArrayList<>(adxPlot.size());
for (int i = 0; i < adxPlot.size(); i++) {
PlotPoint cur = adxPlot.get(i);
if (i < lag) {
out.add(PlotPoint.builder().time(cur.getTime()).value(Double.NaN).build());
continue;
}
Double a = cur.getValue();
Double b = adxPlot.get(i - lag).getValue();
Double v = (a != null && b != null && !a.isNaN() && !b.isNaN()) ? (a + b) / 2.0 : Double.NaN;
out.add(PlotPoint.builder().time(cur.getTime()).value(v).build());
}
return out;
}
private Map<String, List<PlotPoint>> calcAroon(BarSeries s, Map<String, Object> p) {
int len = intP(p, "length", 14);
return Map.of(
"plot0", toPlot(s, new AroonUpIndicator(s, len)),
"plot1", toPlot(s, new AroonDownIndicator(s, len))
);
}
private Map<String, List<PlotPoint>> calcIchimoku(BarSeries s, Map<String, Object> p) {
int conv = intP(p, "conversionPeriods", 9);
int base = intP(p, "basePeriods", 26);
int span2 = intP(p, "laggingSpan2Periods", 52);
int disp = 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);
return Map.of(
"plot0", toPlot(s, tenkan),
"plot1", toPlot(s, kijun),
"plot2", toPlot(s, spanA),
"plot3", toPlot(s, spanB),
"plot4", toPlot(s, chikou)
);
}
private Map<String, List<PlotPoint>> calcParabolicSAR(BarSeries s, Map<String, Object> p) {
double start = dblP(p, "start", 0.02);
double inc = dblP(p, "increment", 0.02);
double max = dblP(p, "maximum", 0.2);
return single("plot0", s, new ParabolicSarIndicator(s,
s.numFactory().numOf(start), s.numFactory().numOf(max), s.numFactory().numOf(inc)));
}
private Map<String, List<PlotPoint>> calcChoppiness(BarSeries s, Map<String, Object> p) {
int len = intP(p, "length", 14);
// Ta4j ChopIndicator(BarSeries, ciTimeFrame, scaleTo=100)
ChopIndicator chop = new ChopIndicator(s, len, 100);
return single("plot0", s, chop);
}
private Map<String, List<PlotPoint>> calcMassIndex(BarSeries s, Map<String, Object> p) {
int emaLen = intP(p, "length2", 9); // inner EMA
int total = intP(p, "length", 25); // total period
return single("plot0", s, new MassIndexIndicator(s, emaLen, total));
}
// ── Volatility ────────────────────────────────────────────────────────────
private Map<String, List<PlotPoint>> calcHV(BarSeries s, Map<String, Object> p) {
int len = intP(p, "length", 20);
int annualBars = intP(p, "annualBars", 252);
int n = s.getBarCount();
ClosePriceIndicator close = new ClosePriceIndicator(s);
List<PlotPoint> pts = new ArrayList<>(n);
double[] logRet = new double[n];
for (int i = 0; i < n; i++) {
if (i == 0) { logRet[i] = 0; continue; }
Double c0 = safe(close.getValue(i - 1));
Double c1 = safe(close.getValue(i));
logRet[i] = (c0 != null && c0 > 0 && c1 != null) ? Math.log(c1 / c0) : 0;
}
double factor = Math.sqrt(annualBars) * 100;
for (int i = 0; i < n; i++) {
Bar bar = s.getBar(i);
long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
if (i < len) { pts.add(new PlotPoint(t, null, null)); continue; }
double mean = 0;
for (int j = i - len + 1; j <= i; j++) mean += logRet[j];
mean /= len;
double var = 0;
for (int j = i - len + 1; j <= i; j++) var += Math.pow(logRet[j] - mean, 2);
double hv = Math.sqrt(var / (len - 1)) * factor;
pts.add(new PlotPoint(t, hv, null));
}
return Map.of("plot0", pts);
}
// ── Williams %R / OBV / TRIX / Volume Osc / VR / 이격도 / 심리도 ─────────
private Map<String, List<PlotPoint>> calcWilliamsR(BarSeries s, Map<String, Object> p) {
int len = intP(p, "length", 14);
// ta4j WilliamsR는 시고저 종가 기준 — 업비트와 동일
return single("plot0", s, new WilliamsRIndicator(s, len));
}
/** 업비트 OBV: OBV + 신호선(SMA/EMA/WMA, 기본 SMA 9) */
private Map<String, List<PlotPoint>> calcOBV(BarSeries s, Map<String, Object> p) {
OnBalanceVolumeIndicator obv = new OnBalanceVolumeIndicator(s);
String maType = p.getOrDefault("maType", "SMA").toString();
int maLen = intP(p, "maLength", 9);
Indicator<Num> maInd = switch (maType) {
case "EMA" -> new EMAIndicator(obv, maLen);
case "WMA" -> new WMAIndicator(obv, maLen);
default -> new SMAIndicator(obv, maLen);
};
return Map.of("plot0", toPlot(s, obv), "plot1", toPlot(s, maInd));
}
/** 업비트 TRIX: ln(종가) → 3×EMA → 1봉 차분 × 10000, 신호선 = TRIX EMA */
private Map<String, List<PlotPoint>> calcTRIX(BarSeries s, Map<String, Object> p) {
int len = intP(p, "length", 12);
int sigLen = intP(p, "signalLength", 9);
Indicator<Num> logClose = UnaryOperationIndicator.log(new ClosePriceIndicator(s));
EMAIndicator e3 = new EMAIndicator(new EMAIndicator(new EMAIndicator(logClose, len), len), len);
PreviousValueIndicator prev3 = new PreviousValueIndicator(e3);
NumericIndicator trix = NumericIndicator.of(e3).minus(prev3).multipliedBy(10_000);
EMAIndicator signal = new EMAIndicator(trix, sigLen);
return Map.of("plot0", toPlot(s, trix), "plot1", toPlot(s, signal));
}
private Map<String, List<PlotPoint>> calcVolumeOscillator(BarSeries s, Map<String, Object> p) {
int shortLen = intP(p, "shortLength", 5);
int longLen = intP(p, "longLength", 10);
VolumeIndicator vol = new VolumeIndicator(s, 1);
EMAIndicator shortEma = new EMAIndicator(vol, shortLen);
EMAIndicator longEma = new EMAIndicator(vol, longLen);
NumericIndicator osc = NumericIndicator.of(shortEma).minus(longEma)
.dividedBy(longEma).multipliedBy(100);
return single("plot0", s, osc);
}
private Map<String, List<PlotPoint>> calcVR(BarSeries s, Map<String, Object> p) {
int period = intP(p, "length", 10);
int n = s.getBarCount();
List<PlotPoint> pts = new ArrayList<>(n);
ClosePriceIndicator close = new ClosePriceIndicator(s);
VolumeIndicator vol = new VolumeIndicator(s, 1);
for (int i = 0; i < n; i++) {
Bar bar = s.getBar(i);
long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
if (i < period) {
pts.add(PlotPoint.builder().time(t).value(null).build());
continue;
}
double upVol = 0, downVol = 0;
for (int j = i - period + 1; j <= i; j++) {
Double c = safe(close.getValue(j));
Double cPrev = safe(close.getValue(j - 1));
Double v = safe(vol.getValue(j));
if (c == null || cPrev == null || v == null) continue;
if (c > cPrev) upVol += v;
else if (c < cPrev) downVol += v;
}
Double val = downVol == 0 ? null : (upVol / downVol) * 100.0;
pts.add(PlotPoint.builder().time(t).value(val).build());
}
return Map.of("plot0", pts);
}
/** 업비트 이격도: 종가 ÷ SMA × 100 */
private Map<String, List<PlotPoint>> calcDisparity(BarSeries s, Map<String, Object> p) {
Indicator<Num> source = src(s, p);
Map<String, List<PlotPoint>> result = new LinkedHashMap<>();
result.put("plot0", disparityLine(s, source, intP(p, "ultraLength", 5)));
result.put("plot1", disparityLine(s, source, intP(p, "shortLength", 10)));
result.put("plot2", disparityLine(s, source, intP(p, "midLength", 20)));
result.put("plot3", disparityLine(s, source, intP(p, "longLength", 60)));
return result;
}
private List<PlotPoint> disparityLine(BarSeries s, Indicator<Num> source, int period) {
SMAIndicator sma = new SMAIndicator(source, period);
int n = s.getBarCount();
List<PlotPoint> pts = new ArrayList<>(n);
for (int i = 0; i < n; i++) {
Bar bar = s.getBar(i);
long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
Double c = safe(source.getValue(i));
Double m = safe(sma.getValue(i));
if (c == null || m == null || m == 0) {
pts.add(PlotPoint.builder().time(t).value(null).build());
} else {
pts.add(PlotPoint.builder().time(t).value(c / m * 100.0).build());
}
}
return pts;
}
private Map<String, List<PlotPoint>> calcPsychological(BarSeries s, Map<String, Object> p) {
int period = intP(p, "length", 12);
return Map.of("plot0", psychologicalPoints(s, period, false));
}
private Map<String, List<PlotPoint>> calcInvestPsychological(BarSeries s, Map<String, Object> p) {
int period = intP(p, "length", 10);
return Map.of("plot0", psychologicalPoints(s, period, true));
}
private List<PlotPoint> psychologicalPoints(BarSeries s, int period, boolean volumeWeighted) {
int n = s.getBarCount();
ClosePriceIndicator close = new ClosePriceIndicator(s);
VolumeIndicator vol = new VolumeIndicator(s, 1);
List<PlotPoint> pts = new ArrayList<>(n);
for (int i = 0; i < n; i++) {
Bar bar = s.getBar(i);
long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
if (i < period) {
pts.add(PlotPoint.builder().time(t).value(null).build());
continue;
}
double val;
if (!volumeWeighted) {
int up = 0;
for (int j = i - period + 1; j <= i; j++) {
Double c = safe(close.getValue(j));
Double cPrev = safe(close.getValue(j - 1));
if (c != null && cPrev != null && c > cPrev) up++;
}
val = (double) up / period * 100.0;
} else {
double upVol = 0, total = 0;
for (int j = i - period + 1; j <= i; j++) {
Double c = safe(close.getValue(j));
Double cPrev = safe(close.getValue(j - 1));
Double v = safe(vol.getValue(j));
if (v == null) continue;
total += v;
if (c != null && cPrev != null && c > cPrev) upVol += v;
}
val = total == 0 ? Double.NaN : upVol / total * 100.0;
}
pts.add(PlotPoint.builder().time(t).value(Double.isNaN(val) ? null : val).build());
}
return pts;
}
// ── EMA helper (double[], for TSI manual calc) ────────────────────────────
private double[] ema(double[] src, int len, int n) {
double[] out = new double[n];
double k = 2.0 / (len + 1);
out[0] = src[0];
for (int i = 1; i < n; i++) {
out[i] = src[i] * k + out[i - 1] * (1 - k);
}
return out;
}
}
@@ -0,0 +1,197 @@
package com.goldenchart.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goldenchart.entity.GcIndicatorSettings;
import com.goldenchart.repository.GcIndicatorSettingsRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* 전역 지표 파라미터 설정 서비스.
*
* <p>프론트엔드 indicatorRegistry.ts 의 defaultParams 를 완전히 대체하며,
* 사용자가 변경한 파라미터를 DB 에 저장하고 백엔드 지표 계산 시 우선 적용한다.</p>
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class IndicatorSettingsService {
private final GcIndicatorSettingsRepository repo;
private final ObjectMapper mapper;
// ── 공개 API ─────────────────────────────────────────────────────────────
/**
* 장치/사용자 식별자로 전체 지표 파라미터 맵을 조회한다.
* 없으면 빈 맵 반환.
*
* @return Map&lt;indicatorType, Map&lt;paramKey, paramValue&gt;&gt;
*/
@Transactional(readOnly = true)
public Map<String, Map<String, Object>> getAll(Long userId, String deviceId) {
return findEntity(userId, deviceId)
.map(s -> parseParamsJson(s.getParamsJson()))
.orElse(Collections.emptyMap());
}
/**
* 특정 지표의 파라미터를 조회한다.
* 없으면 빈 맵 반환.
*/
@Transactional(readOnly = true)
public Map<String, Object> getForType(Long userId, String deviceId, String indicatorType) {
Map<String, Map<String, Object>> all = getAll(userId, deviceId);
return all.getOrDefault(indicatorType, Collections.emptyMap());
}
/**
* 전체 지표 파라미터를 저장(덮어쓰기)한다.
* 프론트엔드에서 전체 설정을 한 번에 저장할 때 사용.
*
* @param allParams Map&lt;indicatorType, Map&lt;paramKey, paramValue&gt;&gt;
*/
public void saveAll(Long userId, String deviceId,
Map<String, Map<String, Object>> allParams) {
GcIndicatorSettings entity = findOrCreate(userId, deviceId);
entity.setParamsJson(toJson(allParams));
repo.save(entity);
log.debug("[IndicatorSettings] saved {} indicator types for device={}", allParams.size(), deviceId);
}
/**
* 특정 지표의 파라미터만 병합(upsert)한다.
* 프론트엔드에서 단일 지표 파라미터를 변경할 때 사용.
*
* @param indicatorType 지표 타입 (e.g. "RSI", "MACD")
* @param params 파라미터 맵 (e.g. {"length": 9, "src": "close"})
*/
public void saveForType(Long userId, String deviceId,
String indicatorType, Map<String, Object> params) {
GcIndicatorSettings entity = findOrCreate(userId, deviceId);
Map<String, Map<String, Object>> all = new HashMap<>(parseParamsJson(entity.getParamsJson()));
all.put(indicatorType, params);
entity.setParamsJson(toJson(all));
repo.save(entity);
log.debug("[IndicatorSettings] saved params for {}:{} device={}", indicatorType, params, deviceId);
}
// ── 시각 설정 (색상·선굵기·수평선) ──────────────────────────────────────
/**
* 전체 지표의 시각 설정을 조회한다.
*
* @return Map&lt;indicatorType, Map{plots, hlines}&gt;
*/
@Transactional(readOnly = true)
public Map<String, Map<String, Object>> getAllVisual(Long userId, String deviceId) {
return findEntity(userId, deviceId)
.map(s -> parseVisualJson(s.getVisualConfigJson()))
.orElse(Collections.emptyMap());
}
/**
* 특정 지표의 시각 설정(색상·선굵기·수평선)을 저장(병합)한다.
*
* @param indicatorType 지표 타입 (e.g. "RSI")
* @param visual {@code {plots:[...], hlines:[...]}} 구조의 맵
*/
public void saveVisualForType(Long userId, String deviceId,
String indicatorType, Map<String, Object> visual) {
GcIndicatorSettings entity = findOrCreate(userId, deviceId);
Map<String, Map<String, Object>> all = new HashMap<>(parseVisualJson(entity.getVisualConfigJson()));
all.put(indicatorType, visual);
entity.setVisualConfigJson(toJson(all));
repo.save(entity);
log.debug("[IndicatorSettings] saved visual for {}:{} device={}", indicatorType, visual, deviceId);
}
/**
* 요청 params 에 없는 키를 DB 설정값으로 채운다.
* IndicatorController 에서 호출 — 하드코딩 기본값 제거.
*
* @param requestParams 프론트엔드가 보낸 파라미터 (null 허용)
* @param indicatorType 지표 타입
* @return 병합된 파라미터 맵 (DB 값 우선, 없으면 요청값)
*/
@Transactional(readOnly = true)
public Map<String, Object> mergeWithDb(Long userId, String deviceId,
Map<String, Object> requestParams,
String indicatorType) {
Map<String, Object> dbParams = getForType(userId, deviceId, indicatorType);
if (dbParams.isEmpty() && (requestParams == null || requestParams.isEmpty())) {
return Map.of(); // 둘 다 없으면 빈 맵 반환 (IndicatorService 내부 기본값 사용)
}
// DB 값 기반 + 요청 파라미터로 덮어쓰기 (요청이 명시적으로 보낸 값 우선)
Map<String, Object> merged = new HashMap<>(dbParams);
if (requestParams != null) {
merged.putAll(requestParams);
}
return merged;
}
// ── Private helpers ───────────────────────────────────────────────────────
private Optional<GcIndicatorSettings> findEntity(Long userId, String deviceId) {
if (userId != null) return repo.findByUserId(userId);
if (deviceId != null) return repo.findByDeviceId(deviceId);
return Optional.empty();
}
private GcIndicatorSettings findOrCreate(Long userId, String deviceId) {
return findEntity(userId, deviceId).orElseGet(() ->
repo.save(GcIndicatorSettings.builder()
.userId(userId)
.deviceId(deviceId)
.paramsJson("{}")
.build()));
}
@SuppressWarnings("unchecked")
private Map<String, Map<String, Object>> parseParamsJson(String json) {
if (json == null || json.isBlank() || "{}".equals(json.trim())) {
return new HashMap<>();
}
try {
return mapper.readValue(json,
new TypeReference<Map<String, Map<String, Object>>>() {});
} catch (JsonProcessingException e) {
log.warn("[IndicatorSettings] JSON 파싱 실패: {}", e.getMessage());
return new HashMap<>();
}
}
@SuppressWarnings("unchecked")
private Map<String, Map<String, Object>> parseVisualJson(String json) {
if (json == null || json.isBlank() || "{}".equals(json.trim())) {
return new HashMap<>();
}
try {
return mapper.readValue(json,
new TypeReference<Map<String, Map<String, Object>>>() {});
} catch (JsonProcessingException e) {
log.warn("[IndicatorSettings] visual JSON 파싱 실패: {}", e.getMessage());
return new HashMap<>();
}
}
private String toJson(Object obj) {
try {
return mapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
log.error("[IndicatorSettings] JSON 직렬화 실패", e);
return "{}";
}
}
}
@@ -0,0 +1,118 @@
package com.goldenchart.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.goldenchart.dto.BacktestSettingsDto;
import com.goldenchart.entity.GcAppSettings;
import com.goldenchart.repository.GcAppSettingsRepository;
import com.goldenchart.trading.TradingExecutionService;
import com.goldenchart.trading.TradingMode;
import com.goldenchart.trading.pipeline.OrderRequest;
import com.goldenchart.upbit.UpbitOrderApiClient;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 실거래 포지션 틱 단위 손절/익절 — 계좌 스냅샷(30초) + 틱 비교.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class LiveRiskMonitorService {
private final GcAppSettingsRepository appSettingsRepo;
private final AppSettingsService appSettingsService;
private final BacktestSettingsService backtestSettingsService;
private final UpbitOrderApiClient upbitApi;
private final LiveTradingService liveTradingService;
private final TradingExecutionService tradingExecutionService;
private final Map<String, Long> exitCooldown = new ConcurrentHashMap<>();
/** deviceId → currency → {balance, avgPrice} */
private final Map<String, Map<String, double[]>> accountCache = new ConcurrentHashMap<>();
private static final long COOLDOWN_MS = 5_000;
@PostConstruct
void init() {
refreshAccounts();
}
@Scheduled(fixedDelay = 30_000)
public void refreshAccounts() {
for (GcAppSettings app : appSettingsRepo.findAll()) {
if (!Boolean.TRUE.equals(app.getLiveAutoTradeEnabled())) continue;
if (!liveTradingService.hasApiKeys(app)) continue;
if (!TradingMode.fromString(app.getTradingMode()).useLive()) continue;
String deviceId = app.getDeviceId();
if (deviceId == null || deviceId.isBlank()) continue;
try {
var creds = appSettingsService.resolveUpbitCredentials(app);
if (!creds.isComplete()) continue;
JsonNode accounts = upbitApi.getAccounts(
creds.accessKey(), creds.secretKey());
Map<String, double[]> coins = new ConcurrentHashMap<>();
if (accounts != null && accounts.isArray()) {
for (JsonNode a : accounts) {
String cur = a.path("currency").asText();
double bal = a.path("balance").asDouble(0);
if (bal <= 0 || "KRW".equals(cur)) continue;
double avg = a.path("avg_buy_price").asDouble(0);
coins.put(cur, new double[]{bal, avg});
}
}
accountCache.put(deviceId, coins);
} catch (Exception e) {
log.debug("[LiveRisk] account sync {}: {}", deviceId, e.getMessage());
}
}
}
public void onTick(String market, double tradePrice) {
if (tradePrice <= 0) return;
String currency = market.startsWith("KRW-") ? market.substring(4) : market;
for (var entry : accountCache.entrySet()) {
String deviceId = entry.getKey();
double[] pos = entry.getValue().get(currency);
if (pos == null || pos[0] <= 0 || pos[1] <= 0) continue;
String key = deviceId + ":" + market;
long now = System.currentTimeMillis();
Long last = exitCooldown.get(key);
if (last != null && now - last < COOLDOWN_MS) continue;
GcAppSettings app = appSettingsRepo.findByDeviceId(deviceId).orElse(null);
if (app == null) continue;
BacktestSettingsDto risk = backtestSettingsService.get(deviceId);
double avg = pos[1];
double pnlPct = (tradePrice - avg) / avg * 100.0;
if (Boolean.TRUE.equals(risk.getStopLossEnabled())
&& risk.getStopLossPct() != null
&& pnlPct <= -risk.getStopLossPct().doubleValue()) {
exitCooldown.put(key, now);
tradingExecutionService.executeRiskExit(
OrderRequest.stopLoss(deviceId, app.getUserId(), market, tradePrice,
risk.getStopLossPct().doubleValue()));
return;
}
if (Boolean.TRUE.equals(risk.getTakeProfitEnabled())
&& risk.getTakeProfitPct() != null
&& pnlPct >= risk.getTakeProfitPct().doubleValue()) {
exitCooldown.put(key, now);
tradingExecutionService.executeRiskExit(
OrderRequest.takeProfit(deviceId, app.getUserId(), market, tradePrice,
risk.getTakeProfitPct().doubleValue()));
return;
}
}
}
}
@@ -0,0 +1,325 @@
package com.goldenchart.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goldenchart.entity.GcLiveStrategySettings;
import com.goldenchart.entity.GcStrategy;
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
import com.goldenchart.repository.GcStrategyRepository;
import com.goldenchart.storage.Ta4jStorage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.ta4j.core.*;
import org.ta4j.core.rules.BooleanRule;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* 실시간 전략 체크 평가기.
*
* <p>두 가지 방식으로 전략 만족 여부를 판정한다:
* <ul>
* <li>방식 A (CANDLE_CLOSE): 봉 마감 직후 BarBuilder 에서 트리거</li>
* <li>방식 B (REALTIME_TICK): 3초 스케줄러에서 현재 진행 중인 캔들 인덱스로 판정</li>
* </ul>
*
* <p>포지션 종속성 모드:
* <ul>
* <li><b>LONG_ONLY</b>: TradingRecord 에 열린 포지션이 있어야 매도 시그널 발생.
* 시장 진입/청산을 {@code strategy.shouldEnter/shouldExit} 로 판정.</li>
* <li><b>SIGNAL_ONLY</b>: 포지션 관계없이 지표 규칙만 충족되면 시그널 확정.
* {@code rule.isSatisfied(index)} 를 직접 호출.</li>
* </ul>
*
* <p>동시성 안전:
* <ul>
* <li>ConcurrentHashMap 으로 market×candleType 키에 대한 Strategy 캐시 관리</li>
* <li>LONG_ONLY 모드용 TradingRecord 캐시도 ConcurrentHashMap 으로 관리</li>
* <li>Ta4jStorage.getOrCreate/updateLastBar 는 내부 synchronized 처리</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class LiveStrategyEvaluator {
private final GcLiveStrategySettingsRepository settingsRepo;
private final GcStrategyRepository strategyRepo;
private final Ta4jStorage ta4jStorage;
private final StrategyDslToTa4jAdapter adapter;
private final IndicatorSettingsService indicatorSettingsService;
private final ObjectMapper objectMapper;
private final StrategySignalDeterminer determiner;
/**
* Strategy 캐시: "market:candleType:strategyId" → Strategy
* (entryRule / exitRule 포함)
*/
private final ConcurrentHashMap<String, Strategy> strategyCache = new ConcurrentHashMap<>();
/**
* LONG_ONLY 모드용 TradingRecord 캐시: "market:candleType:strategyId" → TradingRecord
* 시그널이 발생할 때마다 Record 를 갱신하여 포지션 상태를 유지한다.
*/
private final ConcurrentHashMap<String, BaseTradingRecord> tradingRecordCache = new ConcurrentHashMap<>();
/**
* LONG_ONLY 모드용 포지션 오픈 여부 추적: Ta4j API 버전 차이 대응.
*/
private final ConcurrentHashMap<String, Boolean> positionOpenCache = new ConcurrentHashMap<>();
/**
* REALTIME_TICK 중복 시그널 방지: "market:candleType" → 마지막으로 시그널을 낸 seriesIndex.
* 동일 provisional 봉에서 3초 스케줄러가 반복 실행되더라도 이미 시그널을 낸 인덱스면 무시.
* 새 provisional 봉(다음 분)이 시작되면 자동으로 다른 index 이므로 갱신된다.
*/
private final ConcurrentHashMap<String, Integer> realtimeSignaledIdx = new ConcurrentHashMap<>();
// ── 공개 API ─────────────────────────────────────────────────────────────
/**
* 방식 A — 봉 마감 직후 호출.
*
* @param market 마켓 코드
* @param candleType 캔들 타입
* @param maturedIndex 확정된 봉의 BarSeries 인덱스
* @return "BUY" | "SELL" | "NONE"
*/
public String evaluateCandleClose(String market, String candleType, int maturedIndex) {
List<GcLiveStrategySettings> settings = settingsRepo.findActiveByMarket(market);
if (settings.isEmpty()) return "NONE";
if (!ta4jStorage.exists(market, candleType)) return "NONE";
log.debug("[Evaluator] CANDLE_CLOSE 평가: {} {} idx={} barCount={}",
market, candleType, maturedIndex,
ta4jStorage.getOrCreate(market, candleType).getBarCount());
for (GcLiveStrategySettings s : settings) {
if (!Boolean.TRUE.equals(s.getIsLiveCheck())) continue;
if (!"CANDLE_CLOSE".equals(s.getExecutionType())) continue;
if (s.getStrategyId() == null) continue;
String result = evaluate(market, candleType, s.getStrategyId(),
s.getPositionMode(), maturedIndex,
s.getDeviceId(), s.getUserId());
if (!"NONE".equals(result)) {
log.info("[Evaluator] CANDLE_CLOSE {} {} idx={} mode={} → {}",
market, candleType, maturedIndex, s.getPositionMode(), result);
return result;
}
}
return "NONE";
}
/**
* 방식 B — 3초 스케줄러에서 호출.
*
* <p>동일 provisional 봉(endIndex)에서 CROSS 시그널이 중복 발화되지 않도록
* 마지막 시그널 인덱스를 추적한다. 새 분이 시작되면 endIndex가 증가하므로
* 자동으로 다음 교차 이벤트를 감지할 수 있다.
*
* <p><b>캐시 무효화 정책 (Ta4j CachedIndicator memoization 문제 대응)</b><br>
* Ta4j 의 {@code CachedIndicator} 구현체(CCIIndicator, RSIIndicator 등)는
* {@code getValue(index)} 결과를 내부 리스트에 영구 저장한다.
* {@code Ta4jStorage.updateLastBar} 가 provisional 봉의 close price 를 갱신해도
* 이미 캐시된 값은 변하지 않으므로, CrossedDown/Up Rule 이 구 가격으로 판정한다.
* 이를 방지하기 위해 REALTIME_TICK 평가마다 {@code strategyCache} 를 지워
* 인디케이터 인스턴스를 강제 재생성한다.
*
* @param market 마켓 코드
* @param candleType 캔들 타입
* @return "BUY" | "SELL" | "NONE"
*/
public String evaluateRealtimeTick(String market, String candleType) {
List<GcLiveStrategySettings> settings = settingsRepo.findActiveByMarket(market);
if (settings.isEmpty()) return "NONE";
if (!ta4jStorage.exists(market, candleType)) return "NONE";
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
if (series.isEmpty()) return "NONE";
int currentIndex = series.getEndIndex();
// 동일 provisional 봉에서 이미 시그널을 냈으면 중복 발화 방지
String dedupeKey = market + ":" + candleType;
Integer lastSignaledIdx = realtimeSignaledIdx.get(dedupeKey);
if (lastSignaledIdx != null && lastSignaledIdx == currentIndex) return "NONE";
for (GcLiveStrategySettings s : settings) {
if (!Boolean.TRUE.equals(s.getIsLiveCheck())) continue;
if (!"REALTIME_TICK".equals(s.getExecutionType())) continue;
if (s.getStrategyId() == null) continue;
// ★ Ta4j CachedIndicator 캐시 무효화:
// updateLastBar 로 provisional 봉 가격이 바뀌어도 CCI 등 CachedIndicator 는
// 이미 캐시된 구 값을 반환한다. 전략을 재빌드하면 새 인디케이터 인스턴스를
// 생성(빈 캐시)하므로 getValue(currentIndex) 가 현재 가격으로 재계산된다.
String cacheKey = market + ":" + candleType + ":" + s.getStrategyId();
strategyCache.remove(cacheKey);
String result = evaluate(market, candleType, s.getStrategyId(),
s.getPositionMode(), currentIndex,
s.getDeviceId(), s.getUserId());
if (!"NONE".equals(result)) {
log.info("[Evaluator] REALTIME_TICK {} {} idx={} mode={} → {}",
market, candleType, currentIndex, s.getPositionMode(), result);
realtimeSignaledIdx.put(dedupeKey, currentIndex);
return result;
}
}
return "NONE";
}
/**
* 봉 마감 직후 REALTIME_TICK 설정에 대해 확정봉 인덱스로 전략을 평가한다.
*
* <p>배경: CrossedDown/Up 교차 이벤트가 봉 마감 시점에 발생하면, 3초 스케줄러는
* 이미 다음 분의 provisional 봉(index N+1)을 평가하기 때문에 교차를 감지하지 못한다.
* (CrossedDown(N+1) → CCI(N) < threshold, CCI(N+1) < threshold → 교차 없음)
*
* <p>이 메서드를 {@code BarBuilder.commitBar} 에서 호출하면 CANDLE_CLOSE 와 동일한
* 타이밍에 REALTIME_TICK 전략도 확정봉 인덱스(N)로 평가하여 누락을 방지한다.
*
* @param market 마켓 코드
* @param candleType 캔들 타입
* @param maturedIndex 확정된 봉의 BarSeries 인덱스
* @return "BUY" | "SELL" | "NONE"
*/
public String evaluateRealtimeAtClose(String market, String candleType, int maturedIndex) {
List<GcLiveStrategySettings> settings = settingsRepo.findActiveByMarket(market);
if (settings.isEmpty()) return "NONE";
if (!ta4jStorage.exists(market, candleType)) return "NONE";
for (GcLiveStrategySettings s : settings) {
if (!Boolean.TRUE.equals(s.getIsLiveCheck())) continue;
if (!"REALTIME_TICK".equals(s.getExecutionType())) continue;
if (s.getStrategyId() == null) continue;
// CachedIndicator 캐시 무효화 — 확정봉의 최종 close 로 재계산
String cacheKey = market + ":" + candleType + ":" + s.getStrategyId();
strategyCache.remove(cacheKey);
String result = evaluate(market, candleType, s.getStrategyId(),
s.getPositionMode(), maturedIndex,
s.getDeviceId(), s.getUserId());
if (!"NONE".equals(result)) {
log.info("[Evaluator] REALTIME_TICK @candle_close {} {} idx={} mode={} → {}",
market, candleType, maturedIndex, s.getPositionMode(), result);
realtimeSignaledIdx.put(market + ":" + candleType, maturedIndex);
return result;
}
}
return "NONE";
}
/** 설정 변경 시 해당 마켓 캐시 무효화 */
public void invalidateCache(String market) {
strategyCache.keySet().removeIf(k -> k.startsWith(market + ":"));
tradingRecordCache.keySet().removeIf(k -> k.startsWith(market + ":"));
positionOpenCache.keySet().removeIf(k -> k.startsWith(market + ":"));
realtimeSignaledIdx.keySet().removeIf(k -> k.startsWith(market + ":"));
}
// ── Private ───────────────────────────────────────────────────────────────
private String evaluate(String market, String candleType, long strategyId,
String positionMode, int index,
String deviceId, Long userId) {
String cacheKey = market + ":" + candleType + ":" + strategyId;
// computeIfAbsent 는 device context 전달이 불가하므 수동 분기
Strategy strategy = strategyCache.get(cacheKey);
if (strategy == null) {
strategy = buildStrategy(market, candleType, strategyId, deviceId, userId);
if (strategy != null) strategyCache.put(cacheKey, strategy);
}
if (strategy == null) return "NONE";
try {
String mode = positionMode != null ? positionMode : "LONG_ONLY";
if ("SIGNAL_ONLY".equals(mode)) {
// 포지션 락 우회 — 순수 Rule 충족 여부만 판단
return determiner.determineSignal(strategy, null, index, "SIGNAL_ONLY");
}
// LONG_ONLY — TradingRecord 상태를 유지하여 포지션 검증
BaseTradingRecord record = tradingRecordCache.computeIfAbsent(
cacheKey, k -> new BaseTradingRecord());
boolean isOpen = Boolean.TRUE.equals(positionOpenCache.get(cacheKey));
String signal = determiner.determineSignal(strategy, record, index, "LONG_ONLY");
// 시그널에 따라 TradingRecord 갱신 및 포지션 상태 추적
if ("BUY".equals(signal) && !isOpen) {
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
org.ta4j.core.num.Num price = series.getBar(index).getClosePrice();
record.enter(index, price, series.numFactory().numOf(1));
positionOpenCache.put(cacheKey, true);
} else if ("SELL".equals(signal) && isOpen) {
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
org.ta4j.core.num.Num price = series.getBar(index).getClosePrice();
record.exit(index, price, series.numFactory().numOf(1));
positionOpenCache.put(cacheKey, false);
} else if ("BUY".equals(signal) || "SELL".equals(signal)) {
// 포지션 상태와 불일치 (이미 매수 중인데 또 BUY 등) → 신호 무시
signal = "NONE";
}
return signal;
} catch (Exception e) {
log.warn("[Evaluator] 전략 판정 오류 key={} idx={}: {}", cacheKey, index, e.getMessage());
strategyCache.remove(cacheKey);
tradingRecordCache.remove(cacheKey);
}
return "NONE";
}
private Strategy buildStrategy(String market, String candleType, long strategyId,
String deviceId, Long userId) {
Optional<GcStrategy> strategyOpt = strategyRepo.findById(strategyId);
if (strategyOpt.isEmpty()) return null;
GcStrategy strategy = strategyOpt.get();
if (!ta4jStorage.exists(market, candleType)) return null;
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
// 사용자·장치별 지표 파라미터 우선 로드 (없으면 빈 맵 → adapter 기본값 사용)
Map<String, Map<String, Object>> indicatorParams =
indicatorSettingsService.getAll(userId, deviceId);
int barCount = series.getBarCount();
if (barCount < 2) {
log.warn("[Evaluator] 바 수 부족 strategyId={} market={} barCount={} (최소 2 이상 필요)",
strategyId, market, barCount);
return null;
}
log.debug("[Evaluator] Strategy 빌드: strategyId={} market={} barCount={}", strategyId, market, barCount);
try {
Rule entryRule = buildRule(strategy.getBuyConditionJson(), series, indicatorParams);
Rule exitRule = buildRule(strategy.getSellConditionJson(), series, indicatorParams);
BaseStrategy builtStrategy = new BaseStrategy(entryRule, exitRule);
return builtStrategy;
} catch (Exception e) {
log.error("[Evaluator] Strategy 빌드 실패 strategyId={}: {}", strategyId, e.getMessage());
return null;
}
}
private Rule buildRule(String conditionJson, BarSeries series,
Map<String, Map<String, Object>> params) {
if (conditionJson == null || conditionJson.isBlank()) return new BooleanRule(false);
try {
JsonNode node = objectMapper.readTree(conditionJson);
return adapter.toRule(node, series, params);
} catch (Exception e) {
log.warn("[Evaluator] 조건 JSON 파싱 실패: {}", e.getMessage());
return new BooleanRule(false);
}
}
}
@@ -0,0 +1,285 @@
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 LiveStrategyTimeframeService timeframeService;
// ── 조회 ──────────────────────────────────────────────────────────────────
@Transactional(readOnly = true)
public LiveStrategySettingsDto get(Long userId, String deviceId, String market) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
Optional<GcLiveStrategySettings> entity = findEntity(userId, deviceId, market);
LiveStrategySettingsDto dto = entity.isPresent()
? toDto(entity.get())
: defaultDtoFromApp(market, app);
// 실시간 체크 마스터 스위치·전략 ID는 앱 전역 설정(gc_app_settings) 기준
return mergeGlobalTemplate(dto, app, market);
}
@Transactional(readOnly = true)
public List<LiveStrategySettingsDto> listActive(Long userId, String deviceId) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) || app.getLiveStrategyId() == null) {
return List.of();
}
List<String> watchlist = listWatchlistSymbols(userId, deviceId);
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()
.market(m)
.strategyId(app.getLiveStrategyId())
.liveCheck(true)
.executionType(app.getLiveExecutionType())
.positionMode(app.getLivePositionMode())
.candleType(ct)
.build();
})
.toList();
}
// ── 관심종목 연동 (WatchlistService 에서 호출) ─────────────────────────────
/** 관심종목 등록 시 — DB 관심종목 = 전략 체크 대상 행 생성/갱신 */
public void enableForWatchlistMarket(Long userId, String deviceId, String market) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
LiveStrategySettingsDto template = defaultDtoFromApp(market, app);
template.setLiveCheck(true);
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<LiveStrategySettingsDto> 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<LiveStrategySettingsDto> 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())
.candleType(template.getCandleType() != null ? template.getCandleType() : "1m")
.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);
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 = mergeGlobalTemplate(defaultDtoFromApp(market, app), app, market);
if (dto.getExecutionType() != null) {
out.setExecutionType(dto.getExecutionType());
}
if (dto.getPositionMode() != null) {
out.setPositionMode(dto.getPositionMode());
}
if (dto.getCandleType() != null) {
out.setCandleType(LiveStrategyTimeframeService.normalize(dto.getCandleType()));
}
return out;
}
// ── Private ───────────────────────────────────────────────────────────────
private void persistGlobalTemplate(Long userId, String deviceId, LiveStrategySettingsDto dto) {
Map<String, Object> 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) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
boolean active = Boolean.TRUE.equals(app.getLiveStrategyCheck())
&& app.getLiveStrategyId() != null;
for (String symbol : listWatchlistSymbols(userId, deviceId)) {
LiveStrategySettingsDto row = defaultDtoFromApp(symbol, app);
row.setLiveCheck(active);
LiveStrategySettingsDto saved = upsertEntity(userId, deviceId, row);
if (active) {
pinIfReady(symbol, saved);
}
}
}
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");
entity.setCandleType(LiveStrategyTimeframeService.normalize(
dto.getCandleType() != null ? dto.getCandleType() : "1m"));
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) {
String ct = LiveStrategyTimeframeService.normalize(
dto.getCandleType() != null ? dto.getCandleType() : "1m");
subscriptionManager.ensureMarketPinned(market, ct);
}
}
private List<String> listWatchlistSymbols(Long userId, String deviceId) {
List<GcWatchlist> 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<GcLiveStrategySettings> findEntity(Long userId, String deviceId,
String market) {
if (userId != null) {
Optional<GcLiveStrategySettings> 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) {
return 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();
}
/** 앱 전역 실시간 체크 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")
.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,79 @@
package com.goldenchart.service;
import com.goldenchart.entity.GcAppSettings;
import com.goldenchart.entity.GcLiveStrategySettings;
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
import com.goldenchart.storage.Ta4jStorage;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
import java.util.Set;
/**
* 종목별 전략 평가 분봉 — DB gc_live_strategy_settings.candle_type.
*/
@Service
@RequiredArgsConstructor
public class LiveStrategyTimeframeService {
private static final Set<String> ALLOWED = Ta4jStorage.CANDLE_TYPE_MAP.keySet();
private final GcLiveStrategySettingsRepository repo;
private final AppSettingsService appSettingsService;
@Transactional(readOnly = true)
public String resolveCandleType(String market, Long userId, String deviceId) {
Optional<GcLiveStrategySettings> row = find(userId, deviceId, market);
if (row.isPresent() && row.get().getCandleType() != null) {
return normalize(row.get().getCandleType());
}
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
String tf = app.getDefaultTimeframe();
if (tf != null) {
String mapped = mapChartTfToCandleType(tf);
if (mapped != null) return mapped;
}
return "1m";
}
/**
* 워커 스레드 등 user/device 컨텍스트 없을 때 — 해당 마켓 활성 설정의 candle_type.
*/
@Transactional(readOnly = true)
public String resolveCandleTypeForMarket(String market) {
return repo.findActiveByMarket(market).stream()
.map(GcLiveStrategySettings::getCandleType)
.map(LiveStrategyTimeframeService::normalize)
.findFirst()
.orElse("1m");
}
public static String normalize(String candleType) {
if (candleType == null || candleType.isBlank()) return "1m";
String c = candleType.trim().toLowerCase();
if ("1d".equals(c)) return "1d";
if ("1h".equals(c)) return "1h";
if ("4h".equals(c)) return "4h";
if (ALLOWED.contains(c)) return c;
return "1m";
}
private static String mapChartTfToCandleType(String chartTf) {
return switch (chartTf) {
case "1m", "3m", "5m", "15m", "30m" -> chartTf;
case "1h", "4h" -> chartTf;
case "1D" -> "1d";
case "1W", "1M" -> "1d";
default -> null;
};
}
private Optional<GcLiveStrategySettings> find(Long userId, String deviceId, String market) {
if (userId != null) return repo.findByUserIdAndMarket(userId, market);
if (deviceId != null && !deviceId.isBlank())
return repo.findByDeviceIdAndMarket(deviceId, market);
return Optional.empty();
}
}
@@ -0,0 +1,245 @@
package com.goldenchart.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.goldenchart.dto.LiveOrderRequest;
import com.goldenchart.dto.LiveSummaryDto;
import com.goldenchart.dto.LiveTradeDto;
import com.goldenchart.dto.UpbitApiCredentials;
import com.goldenchart.entity.GcAppSettings;
import com.goldenchart.entity.GcLiveTrade;
import com.goldenchart.repository.GcLiveTradeRepository;
import com.goldenchart.trading.TradingMode;
import com.goldenchart.upbit.UpbitOrderApiClient;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
/**
* 업비트 실거래 — 시그널·손절/익절 시 주문 실행.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class LiveTradingService {
private static final RoundingMode RM = RoundingMode.HALF_UP;
private static final double MIN_ORDER_KRW = 5000;
private final AppSettingsService appSettingsService;
private final UpbitOrderApiClient upbitApi;
private final GcLiveTradeRepository tradeRepo;
@Transactional(readOnly = true)
public LiveSummaryDto getSummary(Long userId, String deviceId) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
UpbitApiCredentials creds = appSettingsService.resolveUpbitCredentials(app);
boolean configured = creds.isComplete();
double krw = 0;
List<LiveSummaryDto.Position> positions = new ArrayList<>();
if (configured) {
try {
krw = upbitApi.getKrwBalance(creds.accessKey(), creds.secretKey());
var accounts = upbitApi.getAccounts(creds.accessKey(), creds.secretKey());
if (accounts != null && accounts.isArray()) {
for (JsonNode a : accounts) {
String cur = a.path("currency").asText();
if ("KRW".equals(cur)) continue;
double bal = a.path("balance").asDouble(0);
if (bal <= 0) continue;
double avg = a.path("avg_buy_price").asDouble(0);
positions.add(LiveSummaryDto.Position.builder()
.symbol("KRW-" + cur)
.quantity(bal)
.avgPrice(avg)
.build());
}
}
} catch (Exception e) {
log.warn("[LiveTrading] summary 실패: {}", e.getMessage());
}
}
return LiveSummaryDto.builder()
.enabled(Boolean.TRUE.equals(app.getLiveAutoTradeEnabled()))
.configured(configured)
.tradingMode(app.getTradingMode() != null ? app.getTradingMode() : "PAPER")
.krwBalance(krw)
.positions(positions)
.build();
}
@Transactional(readOnly = true)
public List<LiveTradeDto> listTrades(Long userId, String deviceId) {
String dev = resolveDeviceId(deviceId);
return tradeRepo.findTop100ByDeviceIdOrderByCreatedAtDesc(dev)
.stream().map(this::toDto).toList();
}
/**
* 전략 시그널·SL/TP 에서 호출.
*/
public void tryExecuteOnSignal(String deviceId, Long userId, String market,
Long strategyId, String signalType, double price,
String source) {
if (deviceId == null || deviceId.isBlank()) return;
if (!"BUY".equals(signalType) && !"SELL".equals(signalType)) return;
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
TradingMode mode = TradingMode.fromString(app.getTradingMode());
if (!mode.useLive()) return;
if (!Boolean.TRUE.equals(app.getLiveAutoTradeEnabled())) return;
UpbitApiCredentials creds = appSettingsService.resolveUpbitCredentials(app);
if (!creds.isComplete()) {
log.debug("[LiveTrading] API 키 없음 — skip {}", market);
return;
}
if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) && "STRATEGY".equals(source)) return;
String access = creds.accessKey();
String secret = creds.secretKey();
try {
if ("BUY".equals(signalType)) {
double budgetPct = app.getLiveAutoTradeBudgetPct() != null
? app.getLiveAutoTradeBudgetPct().doubleValue()
: (app.getPaperAutoTradeBudgetPct() != null
? app.getPaperAutoTradeBudgetPct().doubleValue() : 95);
double krw = upbitApi.getKrwBalance(access, secret);
double budget = krw * budgetPct / 100.0;
if (budget < MIN_ORDER_KRW) {
log.debug("[LiveTrading] BUY skip: budget={}", budget);
return;
}
JsonNode res = upbitApi.placeMarketBuy(access, secret, market, budget);
recordTrade(deviceId, userId, market, "BUY", source, strategyId, res, price, budget);
log.info("[LiveTrading] BUY {} ~{} KRW", market, (long) budget);
} else {
double vol = upbitApi.getCoinBalance(access, secret, market);
if (vol <= 0) {
log.debug("[LiveTrading] SELL skip: no balance {}", market);
return;
}
JsonNode res = upbitApi.placeMarketSell(access, secret, market, vol);
recordTrade(deviceId, userId, market, "SELL", source, strategyId, res, price, vol);
log.info("[LiveTrading] SELL {} vol={}", market, vol);
}
} catch (UpbitOrderApiClient.UpbitApiException e) {
log.warn("[LiveTrading] 주문 실패 {} {}: {}", signalType, market, e.getMessage());
}
}
/**
* 알림 모달·수동 주문 — 시장가 매수/매도.
*/
public LiveTradeDto placeManualOrder(Long userId, String deviceId, LiveOrderRequest req) {
if (req == null || req.getMarket() == null || req.getSide() == null) {
throw new IllegalArgumentException("market, side required");
}
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
UpbitApiCredentials creds = appSettingsService.resolveUpbitCredentials(app);
if (!creds.isComplete()) {
throw new IllegalStateException("업비트 API 키가 등록되지 않았습니다.");
}
String access = creds.accessKey();
String secret = creds.secretKey();
String market = req.getMarket();
String side = req.getSide().toUpperCase();
String dev = resolveDeviceId(deviceId);
try {
if ("BUY".equals(side)) {
double budget = req.getKrwAmount() != null && req.getKrwAmount() > 0
? req.getKrwAmount()
: computeBuyBudget(app, access, secret);
if (budget < MIN_ORDER_KRW) {
throw new IllegalStateException("주문 가능 금액이 부족합니다.");
}
JsonNode res = upbitApi.placeMarketBuy(access, secret, market, budget);
recordTrade(dev, userId, market, "BUY", "MANUAL", req.getStrategyId(), res, 0, budget);
return listTrades(userId, deviceId).get(0);
}
if ("SELL".equals(side)) {
double vol = req.getQuantity() != null && req.getQuantity() > 0
? req.getQuantity()
: upbitApi.getCoinBalance(access, secret, market);
if (vol <= 0) throw new IllegalStateException("매도 가능 수량이 없습니다.");
JsonNode res = upbitApi.placeMarketSell(access, secret, market, vol);
recordTrade(dev, userId, market, "SELL", "MANUAL", req.getStrategyId(), res, 0, vol);
return listTrades(userId, deviceId).get(0);
}
throw new IllegalArgumentException("side must be BUY or SELL");
} catch (UpbitOrderApiClient.UpbitApiException e) {
throw new IllegalStateException("업비트 주문 실패: " + e.getMessage(), e);
}
}
private double computeBuyBudget(GcAppSettings app, String access, String secret) {
double budgetPct = app.getLiveAutoTradeBudgetPct() != null
? app.getLiveAutoTradeBudgetPct().doubleValue()
: (app.getPaperAutoTradeBudgetPct() != null
? app.getPaperAutoTradeBudgetPct().doubleValue() : 95);
try {
double krw = upbitApi.getKrwBalance(access, secret);
return krw * budgetPct / 100.0;
} catch (Exception e) {
log.warn("[LiveTrading] balance read failed: {}", e.getMessage());
return 0;
}
}
public boolean hasApiKeys(GcAppSettings app) {
return appSettingsService.hasUpbitApiKeys(app);
}
private void recordTrade(String deviceId, Long userId, String market, String side,
String source, Long strategyId, JsonNode res,
double refPrice, double amountOrVol) {
String uuid = res != null ? res.path("uuid").asText(null) : null;
double executed = res != null ? res.path("executed_volume").asDouble(amountOrVol) : amountOrVol;
double paid = res != null ? res.path("paid_fee").asDouble(0) : 0;
BigDecimal gross = "BUY".equals(side)
? BigDecimal.valueOf(amountOrVol).setScale(2, RM)
: BigDecimal.valueOf(refPrice * executed).setScale(2, RM);
tradeRepo.save(GcLiveTrade.builder()
.deviceId(resolveDeviceId(deviceId))
.userId(userId)
.symbol(market)
.side(side)
.orderKind("market")
.source(source)
.strategyId(strategyId)
.upbitOrderUuid(uuid)
.price(BigDecimal.valueOf(refPrice).setScale(8, RM))
.quantity(BigDecimal.valueOf(executed).setScale(12, RM))
.grossAmount(gross)
.feeAmount(BigDecimal.valueOf(paid).setScale(2, RM))
.state(res != null ? res.path("state").asText("done") : "done")
.build());
}
private LiveTradeDto toDto(GcLiveTrade t) {
return LiveTradeDto.builder()
.id(t.getId())
.symbol(t.getSymbol())
.side(t.getSide())
.source(t.getSource())
.price(t.getPrice().doubleValue())
.quantity(t.getQuantity().doubleValue())
.grossAmount(t.getGrossAmount().doubleValue())
.upbitOrderUuid(t.getUpbitOrderUuid())
.createdAt(t.getCreatedAt() != null ? t.getCreatedAt().toString() : null)
.build();
}
private static String resolveDeviceId(String deviceId) {
return deviceId != null && !deviceId.isBlank() ? deviceId : "anonymous";
}
}
@@ -0,0 +1,392 @@
package com.goldenchart.service;
import com.goldenchart.dto.*;
import com.goldenchart.entity.GcAppSettings;
import com.goldenchart.entity.GcPaperAccount;
import com.goldenchart.entity.GcPaperPosition;
import com.goldenchart.entity.GcPaperTrade;
import com.goldenchart.repository.GcPaperAccountRepository;
import com.goldenchart.repository.GcPaperPositionRepository;
import com.goldenchart.repository.GcPaperTradeRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 모의투자(페이퍼 트레이딩) 체결·잔고·포지션 관리.
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class PaperTradingService {
private static final int SCALE_QTY = 12;
private static final int SCALE_KRW = 2;
private static final RoundingMode RM = RoundingMode.HALF_UP;
private final GcPaperAccountRepository accountRepo;
private final GcPaperPositionRepository positionRepo;
private final GcPaperTradeRepository tradeRepo;
private final AppSettingsService appSettingsService;
// ── 조회 ─────────────────────────────────────────────────────────────────
public PaperSummaryDto getSummary(Long userId, String deviceId, Map<String, Double> markPrices) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
List<GcPaperPosition> positions = positionRepo.findByAccountIdOrderBySymbolAsc(account.getId());
double stockEval = 0;
double unrealized = 0;
List<PaperPositionDto> posDtos = new ArrayList<>();
for (GcPaperPosition p : positions) {
double qty = p.getQuantity().doubleValue();
if (qty <= 0) continue;
double avg = p.getAvgPrice().doubleValue();
Double mark = markPrices != null ? markPrices.get(p.getSymbol()) : null;
double eval = mark != null ? mark * qty : avg * qty;
double pl = mark != null ? (mark - avg) * qty : 0;
double plPct = avg > 0 && mark != null ? (mark - avg) / avg * 100 : 0;
stockEval += eval;
unrealized += pl;
posDtos.add(PaperPositionDto.builder()
.id(p.getId())
.symbol(p.getSymbol())
.koreanName(p.getKoreanName())
.quantity(qty)
.avgPrice(avg)
.markPrice(mark)
.evalAmount(eval)
.profitLoss(pl)
.profitLossPct(plPct)
.build());
}
double cash = account.getCashBalance().doubleValue();
double initial = app.getPaperInitialCapital() != null
? app.getPaperInitialCapital().doubleValue() : 10_000_000;
double total = cash + stockEval;
double returnPct = initial > 0 ? (total - initial) / initial * 100 : 0;
return PaperSummaryDto.builder()
.enabled(Boolean.TRUE.equals(app.getPaperTradingEnabled()))
.initialCapital(initial)
.cashBalance(cash)
.stockEvalAmount(stockEval)
.totalAsset(total)
.unrealizedPnl(unrealized)
.realizedPnl(account.getRealizedPnl().doubleValue())
.totalReturnPct(returnPct)
.feeRatePct(app.getPaperFeeRatePct() != null ? app.getPaperFeeRatePct().doubleValue() : 0.05)
.slippagePct(app.getPaperSlippagePct() != null ? app.getPaperSlippagePct().doubleValue() : 0)
.minOrderKrw(app.getPaperMinOrderKrw() != null ? app.getPaperMinOrderKrw().doubleValue() : 5000)
.autoTradeEnabled(Boolean.TRUE.equals(app.getPaperAutoTradeEnabled()))
.autoTradeBudgetPct(app.getPaperAutoTradeBudgetPct() != null
? app.getPaperAutoTradeBudgetPct().doubleValue() : 95)
.positions(posDtos)
.build();
}
public List<PaperTradeDto> listTrades(Long userId, String deviceId) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
return tradeRepo.findTop100ByAccountIdOrderByCreatedAtDesc(account.getId())
.stream().map(this::toTradeDto).toList();
}
// ── 주문 ─────────────────────────────────────────────────────────────────
public PaperTradeDto placeOrder(Long userId, String deviceId, PaperOrderRequest req) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) {
throw new IllegalStateException("모의투자가 비활성화되어 있습니다. 설정에서 모의투자를 켜 주세요.");
}
if (req.getMarket() == null || req.getSide() == null) {
throw new IllegalArgumentException("종목과 매수/매도 구분이 필요합니다.");
}
String side = req.getSide().toUpperCase();
if (!"BUY".equals(side) && !"SELL".equals(side)) {
throw new IllegalArgumentException("side는 BUY 또는 SELL 이어야 합니다.");
}
double price = req.getPrice() != null ? req.getPrice() : 0;
if (price <= 0) {
throw new IllegalArgumentException("가격은 0보다 커야 합니다.");
}
String source = req.getSource() != null ? req.getSource() : "MANUAL";
String orderKind = req.getOrderKind() != null ? req.getOrderKind() : "limit";
double qty = req.getQuantity() != null ? req.getQuantity() : 0;
if (qty <= 0) {
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
qty = resolveAutoQuantity(account, app, req.getMarket(), side, price,
req.getBudgetPct(), req.getKrwAmount());
}
if (qty <= 0) {
throw new IllegalArgumentException("주문 수량을 계산할 수 없습니다.");
}
return executeTrade(userId, deviceId, app, req.getMarket(), side, orderKind, source,
req.getStrategyId(), price, qty, null);
}
/**
* 실시간 전략 시그널 발생 시 자동 모의매매.
* liveStrategyCheck + paperAutoTradeEnabled + paperTradingEnabled 일 때만 실행.
*/
public void tryExecuteOnSignal(String deviceId, Long userId, String market,
Long strategyId, String signalType, double price) {
if (deviceId == null || deviceId.isBlank()) return;
if (!"BUY".equals(signalType) && !"SELL".equals(signalType)) return;
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) return;
if (!Boolean.TRUE.equals(app.getPaperAutoTradeEnabled())) return;
if (!Boolean.TRUE.equals(app.getLiveStrategyCheck())) return;
try {
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
double slip = pct(app.getPaperSlippagePct(), 0);
double minOrder = app.getPaperMinOrderKrw() != null
? app.getPaperMinOrderKrw().doubleValue() : 5000;
if ("BUY".equals(signalType)) {
double budgetPct = pct(app.getPaperAutoTradeBudgetPct(), 95);
double cash = account.getCashBalance().doubleValue();
double budget = cash * budgetPct / 100.0;
double execPrice = price * (1 + slip / 100.0);
double denom = execPrice * (1 + feeRate / 100.0);
if (denom <= 0 || budget < minOrder) {
log.debug("[Paper] auto BUY skip: budget={} min={}", budget, minOrder);
return;
}
double qty = budget / denom;
if (qty * execPrice < minOrder) return;
executeTrade(userId, deviceId, app, market, "BUY", "market", "STRATEGY",
strategyId, price, qty, null);
log.info("[Paper] STRATEGY BUY {} qty≈{} @ {}", market, qty, price);
} else {
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElse(null);
if (pos == null || pos.getQuantity().doubleValue() <= 0) {
log.debug("[Paper] auto SELL skip: no position {}", market);
return;
}
double qty = pos.getQuantity().doubleValue();
executeTrade(userId, deviceId, app, market, "SELL", "market", "STRATEGY",
strategyId, price, qty, null);
log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, qty, price);
}
} catch (Exception e) {
log.warn("[Paper] auto trade failed {} {}: {}", market, signalType, e.getMessage());
}
}
/** 계좌 초기화 — 설정의 초기 자본으로 리셋 */
public PaperSummaryDto resetAccount(Long userId, String deviceId) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
BigDecimal initial = app.getPaperInitialCapital() != null
? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000);
positionRepo.findByAccountIdOrderBySymbolAsc(account.getId())
.forEach(positionRepo::delete);
account.setCashBalance(initial);
account.setRealizedPnl(BigDecimal.ZERO);
accountRepo.save(account);
log.info("[Paper] account reset device={} capital={}", deviceId, initial);
return getSummary(userId, deviceId, null);
}
/**
* 수량 미입력(0) 시 자동매매와 동일한 방식으로 수량 산출.
* 매수: 가용현금 × budgetPct(또는 krwAmount) / (체결가 × (1+수수료))
* 매도: 보유수량 × budgetPct
*/
private double resolveAutoQuantity(GcPaperAccount account, GcAppSettings app,
String market, String side,
double price, Double budgetPct, Double krwAmount) {
double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
double slip = pct(app.getPaperSlippagePct(), 0);
double pctVal = budgetPct != null ? budgetPct : pct(app.getPaperAutoTradeBudgetPct(), 95);
if ("BUY".equals(side)) {
double cash = account.getCashBalance().doubleValue();
double budget = krwAmount != null && krwAmount > 0
? krwAmount
: cash * pctVal / 100.0;
double execPrice = price * (1 + slip / 100.0);
double denom = execPrice * (1 + feeRate / 100.0);
if (denom <= 0 || budget <= 0) {
throw new IllegalStateException("주문 가능 금액이 부족합니다.");
}
return budget / denom;
}
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다."));
double held = pos.getQuantity().doubleValue();
if (held <= 0) {
throw new IllegalStateException("보유 수량이 없습니다.");
}
return held * pctVal / 100.0;
}
// ── 내부 체결 ───────────────────────────────────────────────────────────
private PaperTradeDto executeTrade(Long userId, String deviceId, GcAppSettings app,
String market, String side, String orderKind, String source,
Long strategyId, double inputPrice, double inputQty,
String koreanName) {
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
double slip = pct(app.getPaperSlippagePct(), 0);
double minOrder = app.getPaperMinOrderKrw() != null
? app.getPaperMinOrderKrw().doubleValue() : 5000;
double execPrice = "BUY".equals(side)
? inputPrice * (1 + slip / 100.0)
: inputPrice * (1 - slip / 100.0);
BigDecimal qty = BigDecimal.valueOf(inputQty).setScale(SCALE_QTY, RM);
BigDecimal price = BigDecimal.valueOf(execPrice).setScale(SCALE_KRW, RM);
BigDecimal gross = price.multiply(qty).setScale(SCALE_KRW, RM);
BigDecimal fee = gross.multiply(BigDecimal.valueOf(feeRate / 100.0)).setScale(SCALE_KRW, RM);
if (gross.doubleValue() < minOrder) {
throw new IllegalArgumentException("최소 주문 금액은 " + (long) minOrder + " KRW 입니다.");
}
if ("BUY".equals(side)) {
BigDecimal totalCost = gross.add(fee);
if (account.getCashBalance().compareTo(totalCost) < 0) {
throw new IllegalStateException("주문가능 현금이 부족합니다.");
}
account.setCashBalance(account.getCashBalance().subtract(totalCost));
upsertPositionBuy(account, market, koreanName, qty, price);
accountRepo.save(account);
GcPaperTrade trade = tradeRepo.save(GcPaperTrade.builder()
.accountId(account.getId())
.symbol(market)
.side("BUY")
.orderKind(orderKind)
.source(source)
.strategyId(strategyId)
.price(price)
.quantity(qty)
.grossAmount(gross)
.feeAmount(fee)
.netAmount(totalCost.negate())
.cashAfter(account.getCashBalance())
.build());
return toTradeDto(trade);
}
// SELL
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다."));
if (pos.getQuantity().compareTo(qty) < 0) {
throw new IllegalStateException("매도 수량이 보유 수량을 초과합니다.");
}
BigDecimal netProceeds = gross.subtract(fee);
BigDecimal costBasis = pos.getAvgPrice().multiply(qty).setScale(SCALE_KRW, RM);
BigDecimal realizedDelta = netProceeds.subtract(costBasis);
account.setCashBalance(account.getCashBalance().add(netProceeds));
account.setRealizedPnl(account.getRealizedPnl().add(realizedDelta));
BigDecimal remaining = pos.getQuantity().subtract(qty);
if (remaining.compareTo(BigDecimal.valueOf(0.00000001)) <= 0) {
positionRepo.delete(pos);
} else {
pos.setQuantity(remaining);
positionRepo.save(pos);
}
accountRepo.save(account);
GcPaperTrade trade = tradeRepo.save(GcPaperTrade.builder()
.accountId(account.getId())
.symbol(market)
.side("SELL")
.orderKind(orderKind)
.source(source)
.strategyId(strategyId)
.price(price)
.quantity(qty)
.grossAmount(gross)
.feeAmount(fee)
.netAmount(netProceeds)
.cashAfter(account.getCashBalance())
.build());
return toTradeDto(trade);
}
private void upsertPositionBuy(GcPaperAccount account, String market, String koreanName,
BigDecimal qty, BigDecimal price) {
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElse(null);
if (pos == null) {
positionRepo.save(GcPaperPosition.builder()
.accountId(account.getId())
.symbol(market)
.koreanName(koreanName)
.quantity(qty)
.avgPrice(price)
.build());
return;
}
BigDecimal totalQty = pos.getQuantity().add(qty);
BigDecimal totalCost = pos.getAvgPrice().multiply(pos.getQuantity()).add(price.multiply(qty));
BigDecimal avg = totalCost.divide(totalQty, SCALE_KRW, RM);
pos.setQuantity(totalQty);
pos.setAvgPrice(avg);
if (koreanName != null) pos.setKoreanName(koreanName);
positionRepo.save(pos);
}
private GcPaperAccount getOrCreateAccount(Long userId, String deviceId, GcAppSettings app) {
String dev = deviceId != null && !deviceId.isBlank() ? deviceId : "anonymous";
return accountRepo.findByDeviceId(dev).orElseGet(() -> {
BigDecimal initial = app.getPaperInitialCapital() != null
? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000);
GcPaperAccount a = GcPaperAccount.builder()
.userId(userId)
.deviceId(dev)
.cashBalance(initial)
.realizedPnl(BigDecimal.ZERO)
.build();
return accountRepo.save(a);
});
}
private static double pct(BigDecimal v, double def) {
return v != null ? v.doubleValue() : def;
}
private PaperTradeDto toTradeDto(GcPaperTrade t) {
return PaperTradeDto.builder()
.id(t.getId())
.symbol(t.getSymbol())
.side(t.getSide())
.orderKind(t.getOrderKind())
.source(t.getSource())
.strategyId(t.getStrategyId())
.price(t.getPrice().doubleValue())
.quantity(t.getQuantity().doubleValue())
.grossAmount(t.getGrossAmount().doubleValue())
.feeAmount(t.getFeeAmount().doubleValue())
.netAmount(t.getNetAmount().doubleValue())
.cashAfter(t.getCashAfter().doubleValue())
.createdAt(t.getCreatedAt() != null ? t.getCreatedAt().toString() : null)
.build();
}
}
@@ -0,0 +1,95 @@
package com.goldenchart.service;
import com.goldenchart.auth.MenuIds;
import com.goldenchart.auth.UserRole;
import com.goldenchart.dto.MenuPermissionsResponse;
import com.goldenchart.dto.RolePermissionsUpdateRequest;
import com.goldenchart.entity.GcRoleMenuPermission;
import com.goldenchart.entity.GcUser;
import com.goldenchart.repository.GcRoleMenuPermissionRepository;
import com.goldenchart.repository.GcUserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class RolePermissionService {
private final GcRoleMenuPermissionRepository permRepo;
private final GcUserRepository userRepo;
@Transactional(readOnly = true)
public MenuPermissionsResponse resolveForUserId(Long userId) {
if (userId == null) {
return buildForRole(UserRole.GUEST.name());
}
GcUser user = userRepo.findById(userId)
.filter(u -> Boolean.TRUE.equals(u.getEnabled()))
.orElse(null);
if (user == null) {
return buildForRole(UserRole.GUEST.name());
}
String role = user.getRole() != null ? user.getRole() : UserRole.USER.name();
return buildForRole(role);
}
@Transactional(readOnly = true)
public MenuPermissionsResponse buildForRole(String role) {
String r = UserRole.fromString(role).name();
Map<String, Boolean> map = defaultMap();
for (GcRoleMenuPermission p : permRepo.findByRole(r)) {
map.put(p.getMenuId(), Boolean.TRUE.equals(p.getAllowed()));
}
return MenuPermissionsResponse.builder().role(r).permissions(map).build();
}
@Transactional(readOnly = true)
public Map<String, Map<String, Boolean>> listAllRolePermissions() {
Map<String, Map<String, Boolean>> out = new LinkedHashMap<>();
for (UserRole role : UserRole.values()) {
out.put(role.name(), buildForRole(role.name()).getPermissions());
}
return out;
}
@Transactional
public void updateRolePermissions(RolePermissionsUpdateRequest req) {
if (req == null || req.getRole() == null || req.getPermissions() == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "role과 permissions가 필요합니다.");
}
String role = UserRole.fromString(req.getRole()).name();
permRepo.deleteByRole(role);
List<GcRoleMenuPermission> rows = new ArrayList<>();
for (String menuId : MenuIds.ALL) {
Boolean allowed = req.getPermissions().getOrDefault(menuId, false);
rows.add(GcRoleMenuPermission.builder()
.role(role)
.menuId(menuId)
.allowed(allowed)
.build());
}
permRepo.saveAll(rows);
}
@Transactional(readOnly = true)
public void requireAdmin(Long userId) {
GcUser user = userRepo.findById(userId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."));
if (!UserRole.ADMIN.name().equalsIgnoreCase(user.getRole())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "관리자 권한이 필요합니다.");
}
if (!Boolean.TRUE.equals(user.getEnabled())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "비활성화된 계정입니다.");
}
}
private static Map<String, Boolean> defaultMap() {
return MenuIds.ALL.stream().collect(Collectors.toMap(id -> id, id -> false, (a, b) -> b, LinkedHashMap::new));
}
}
@@ -0,0 +1,601 @@
package com.goldenchart.service;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Indicator;
import org.ta4j.core.Rule;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.indicators.*;
import org.ta4j.core.indicators.adx.*;
import org.ta4j.core.indicators.averages.*;
import org.ta4j.core.indicators.bollinger.*;
import org.ta4j.core.indicators.helpers.*;
import org.ta4j.core.indicators.ichimoku.*;
import org.ta4j.core.indicators.statistics.StandardDeviationIndicator;
import org.ta4j.core.indicators.CachedIndicator;
import org.ta4j.core.indicators.helpers.VolumeIndicator;
import org.ta4j.core.indicators.volume.OnBalanceVolumeIndicator;
import org.ta4j.core.indicators.numeric.NumericIndicator;
import org.ta4j.core.indicators.numeric.UnaryOperationIndicator;
import org.ta4j.core.num.Num;
import org.ta4j.core.rules.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* frontend DSL(LogicNode 트리) → Ta4j Rule 변환 어댑터.
*
* <h3>DSL 타입 → 레지스트리(DB) 키 매핑</h3>
* <p>frontend strategyTypes.ts 의 PaletteItem.value (DSL indicatorType) 는
* 대문자 스네이크케이스이지만, indicatorRegistry.ts 의 type 키(=DB 저장 키)는
* PascalCase 이므로 반드시 변환 후 DB 파라미터를 조회해야 한다.</p>
*
* <h3>DSL 구조</h3>
* <pre>
* {
* "type": "AND" | "OR" | "NOT" | "CONDITION",
* "children": [ ...LogicNode ], // AND/OR/NOT 인 경우
* "condition": { // CONDITION 인 경우
* "indicatorType": "CCI",
* "conditionType": "CROSS_UP",
* "leftField": "CCI_VALUE",
* "rightField": "OVERBOUGHT_100",
* "targetValue": null,
* "slopePeriod": 3,
* "holdDays": 3,
* "candleRange": 1
* }
* }
* </pre>
*/
@Component
@Slf4j
public class StrategyDslToTa4jAdapter {
/**
* DSL indicatorType(대문자 스네이크) → indicatorRegistry type(PascalCase, DB 키).
* frontend strategyTypes.ts DSL_TO_REGISTRY 와 동기화.
*/
private static final Map<String, String> DSL_TO_REGISTRY = Map.ofEntries(
Map.entry("RSI", "RSI"),
Map.entry("MACD", "MACD"),
Map.entry("CCI", "CCI"),
Map.entry("ADX", "ADX"),
Map.entry("DMI", "DMI"),
Map.entry("OBV", "OBV"),
Map.entry("TRIX", "TRIX"),
Map.entry("EMA", "EMA"),
Map.entry("MA", "SMA"),
Map.entry("BOLLINGER", "BollingerBands"),
Map.entry("STOCHASTIC", "Stochastic"),
Map.entry("WILLIAMS_R", "WilliamsPercentRange"),
Map.entry("ICHIMOKU", "IchimokuCloud"),
Map.entry("VOLUME_OSC", "VolumeOscillator"),
Map.entry("PSYCHOLOGICAL", "Psychological"),
Map.entry("NEW_PSYCHOLOGICAL", "Psychological"),
Map.entry("INVEST_PSYCHOLOGICAL", "InvestPsychological"),
Map.entry("BWI", "BBBandWidth"),
Map.entry("VR", "VR"),
Map.entry("DISPARITY", "Disparity"),
Map.entry("VOLUME", "VOLUME")
);
/**
* DSL indicatorType 을 DB 저장 키(레지스트리 type)로 변환.
* 매핑 없으면 원본 반환 (이미 레지스트리 형식인 경우).
*/
private static String toRegistryKey(String dslType) {
return DSL_TO_REGISTRY.getOrDefault(dslType, dslType);
}
// ── Public API ────────────────────────────────────────────────────────────
public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams) {
if (node == null || node.isNull()) return new BooleanRule(false);
String type = node.path("type").asText("CONDITION");
return switch (type) {
case "AND" -> buildAndRule(node, series, indicatorParams);
case "OR" -> buildOrRule(node, series, indicatorParams);
case "NOT" -> buildNotRule(node, series, indicatorParams);
default -> buildConditionRule(node.path("condition"), series, indicatorParams);
};
}
// ── AND / OR / NOT ────────────────────────────────────────────────────────
private Rule buildAndRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) {
List<Rule> rules = childRules(node, series, p);
if (rules.isEmpty()) return new BooleanRule(false);
Rule result = rules.get(0);
for (int i = 1; i < rules.size(); i++) result = new AndRule(result, rules.get(i));
return result;
}
private Rule buildOrRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) {
List<Rule> rules = childRules(node, series, p);
if (rules.isEmpty()) return new BooleanRule(false);
Rule result = rules.get(0);
for (int i = 1; i < rules.size(); i++) result = new OrRule(result, rules.get(i));
return result;
}
private Rule buildNotRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) {
List<Rule> rules = childRules(node, series, p);
if (rules.isEmpty()) return new BooleanRule(false);
return new NotRule(rules.get(0));
}
private List<Rule> childRules(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) {
List<Rule> list = new ArrayList<>();
JsonNode children = node.path("children");
if (children.isArray()) {
for (JsonNode child : children) list.add(toRule(child, series, p));
}
return list;
}
// ── CONDITION ─────────────────────────────────────────────────────────────
private Rule buildConditionRule(JsonNode cond, BarSeries series,
Map<String, Map<String, Object>> params) {
if (cond == null || cond.isNull()) return new BooleanRule(false);
String indType = cond.path("indicatorType").asText("");
String condType = cond.path("conditionType").asText("GT");
String leftField = cond.path("leftField").asText("NONE");
String rightField = cond.path("rightField").asText("NONE");
int slopePeriod = cond.path("slopePeriod").asInt(3);
int holdDays = cond.path("holdDays").asInt(3);
// DSL 타입(STOCHASTIC 등) → DB 레지스트리 키(Stochastic 등) 변환 후 파라미터 조회
String registryKey = toRegistryKey(indType);
Map<String, Object> indParams = params != null
? params.getOrDefault(registryKey, Map.of())
: Map.of();
try {
Indicator<Num> left = resolveField(leftField, indType, indParams, series);
Indicator<Num> right = resolveField(rightField, indType, indParams, series);
return switch (condType) {
case "GT" -> new OverIndicatorRule(left, right);
case "LT" -> new UnderIndicatorRule(left, right);
// GTE/LTE: OverIndicator OR 동일 (epsilon 비교)
case "GTE" -> new OrRule(new OverIndicatorRule(left, right),
buildEqRule(left, right, series));
case "LTE" -> new OrRule(new UnderIndicatorRule(left, right),
buildEqRule(left, right, series));
case "EQ" -> buildEqRule(left, right, series);
case "NEQ" -> new NotRule(buildEqRule(left, right, series));
// CrossedUp/Down: ta4j 0.22 에서는 2인자 생성자만 있음
case "CROSS_UP" -> new CrossedUpIndicatorRule(left, right);
case "CROSS_DOWN" -> new CrossedDownIndicatorRule(left, right);
case "SLOPE_UP" -> new IsRisingRule(left, slopePeriod);
case "SLOPE_DOWN" -> new IsFallingRule(left, slopePeriod);
case "DIFF_GT" -> {
double v = cond.path("compareValue").asDouble(0);
yield new OverIndicatorRule(
NumericIndicator.of(left).minus(right).abs(),
new ConstantIndicator<>(series, series.numFactory().numOf(v)));
}
case "DIFF_LT" -> {
double v = cond.path("compareValue").asDouble(0);
yield new UnderIndicatorRule(
NumericIndicator.of(left).minus(right).abs(),
new ConstantIndicator<>(series, series.numFactory().numOf(v)));
}
case "HOLD_N_DAYS" -> buildHoldRule(new OverIndicatorRule(left, right), holdDays);
// ── 일목균형표 전용 ────────────────────────────────────────────
case "ABOVE_CLOUD" -> buildCloudAboveRule(series, indParams);
case "BELOW_CLOUD" -> buildCloudBelowRule(series, indParams);
case "IN_CLOUD" -> buildInCloudRule(series, indParams);
case "CLOUD_BREAK_UP" -> buildCloudBreakRule(series, indParams, true);
case "CLOUD_BREAK_DOWN" -> buildCloudBreakRule(series, indParams, false);
case "SPAN1_GT_SPAN2" -> new OverIndicatorRule(ichimokuSpanA(series, indParams), ichimokuSpanB(series, indParams));
case "SPAN1_LT_SPAN2" -> new UnderIndicatorRule(ichimokuSpanA(series, indParams), ichimokuSpanB(series, indParams));
case "SPAN1_CROSS_UP_SPAN2" -> new CrossedUpIndicatorRule(ichimokuSpanA(series, indParams), ichimokuSpanB(series, indParams));
case "SPAN1_CROSS_DOWN_SPAN2" -> new CrossedDownIndicatorRule(ichimokuSpanA(series, indParams), ichimokuSpanB(series, indParams));
case "LAGGING_GT_PRICE" -> new OverIndicatorRule(ichimokuLagging(series, indParams), new ClosePriceIndicator(series));
case "LAGGING_LT_PRICE" -> new UnderIndicatorRule(ichimokuLagging(series, indParams), new ClosePriceIndicator(series));
default -> {
log.warn("[Adapter] 미지원 conditionType: {}", condType);
yield new BooleanRule(false);
}
};
} catch (Exception e) {
log.warn("[Adapter] 조건 빌드 실패 ind={} cond={}: {}", indType, condType, e.getMessage());
return new BooleanRule(false);
}
}
// ── 필드 Resolver ─────────────────────────────────────────────────────────
private Indicator<Num> resolveField(String field, String indType,
Map<String, Object> p, BarSeries s) {
if (field == null || field.isBlank() || field.equals("NONE")) {
return new ConstantIndicator<>(s, s.numFactory().numOf(0));
}
return switch (field) {
case "CLOSE_PRICE" -> new ClosePriceIndicator(s);
case "OPEN_PRICE" -> new OpenPriceIndicator(s);
case "HIGH_PRICE" -> new HighPriceIndicator(s);
case "LOW_PRICE" -> new LowPriceIndicator(s);
case "VOLUME_VALUE" -> new VolumeIndicator(s, 1);
default -> {
double constant = resolveConstantField(field);
if (!Double.isNaN(constant)) {
yield new ConstantIndicator<>(s, s.numFactory().numOf(constant));
}
yield resolveIndicatorField(field, indType, p, s);
}
};
}
private double resolveConstantField(String field) {
// K_ 접두사: 프론트엔드가 hline 실제값으로 생성한 상수 필드
// 예) K_60 → 60, K_-100 → -100, K_0 → 0
if (field.startsWith("K_")) {
try { return Double.parseDouble(field.substring(2)); } catch (NumberFormatException e) { /* fall */ }
}
// NEG 접두사 (레거시): OVERSOLD_NEG80 → -80
if (field.contains("_NEG")) {
String[] parts = field.split("_NEG");
try { return -Double.parseDouble(parts[parts.length - 1]); } catch (NumberFormatException e) { /* fall */ }
}
if (field.equals("ZERO_LINE")) return 0.0;
// 마지막 _ 이후 숫자 추출 (레거시): OVERBOUGHT_70 → 70, ADX_25 → 25
int idx = field.lastIndexOf('_');
if (idx >= 0 && idx < field.length() - 1) {
try { return Double.parseDouble(field.substring(idx + 1)); } catch (NumberFormatException e) { /* fall */ }
}
return Double.NaN;
}
private Indicator<Num> resolveIndicatorField(String field, String indType,
Map<String, Object> p, BarSeries s) {
ClosePriceIndicator close = new ClosePriceIndicator(s);
// CCI — 프론트엔드 indicatorRegistry 기본값 20 과 통일
if (field.equals("CCI_VALUE") || indType.equals("CCI"))
return new CCIIndicator(s, intP(p, "length", 13));
// RSI — 표준 기본값 14
if (field.equals("RSI_VALUE"))
return new RSIIndicator(close, intP(p, "length", 14));
// MACD
if (field.equals("MACD_LINE")) {
return new MACDIndicator(close, intP(p, "fastLength", 12), intP(p, "slowLength", 26));
}
if (field.equals("SIGNAL_LINE")) {
return new EMAIndicator(
new MACDIndicator(close, intP(p, "fastLength", 12), intP(p, "slowLength", 26)),
intP(p, "signalLength", 9));
}
if (field.equals("HISTOGRAM")) {
MACDIndicator macd = new MACDIndicator(close, intP(p, "fastLength", 12), intP(p, "slowLength", 26));
return NumericIndicator.of(macd).minus(new EMAIndicator(macd, intP(p, "signalLength", 9)));
}
// ADX / DMI
if (field.equals("ADX_VALUE"))
return new ADXIndicator(s, intP(p, "diLength", 14), intP(p, "adxSmoothing", 14));
if (field.equals("PDI") || field.equals("PLUS_DI")) {
int diLen = intP(p, "diLength", intP(p, "length", 14));
return new PlusDIIndicator(s, diLen);
}
if (field.equals("MDI") || field.equals("MINUS_DI")) {
int diLen = intP(p, "diLength", intP(p, "length", 14));
return new MinusDIIndicator(s, diLen);
}
// Stochastic Slow — IndicatorService.calcStochastic 과 동일한 로직:
// raw %K = StochasticOscillatorKIndicator(kLen)
// slow %K = SMA(raw %K, smooth)
// %D = SMA(slow %K, dSmoothing)
if (field.equals("STOCH_K")) {
int kLen = intP(p, "kLength", 14);
int smooth = intP(p, "smooth", 3);
return new SMAIndicator(new StochasticOscillatorKIndicator(s, kLen), smooth);
}
if (field.equals("STOCH_D")) {
int kLen = intP(p, "kLength", 14);
int smooth = intP(p, "smooth", 3);
int dSmooth = intP(p, "dSmoothing", 3);
SMAIndicator slowK = new SMAIndicator(new StochasticOscillatorKIndicator(s, kLen), smooth);
return new SMAIndicator(slowK, dSmooth);
}
// TRIX — ln(종가) → 3×EMA → Δ×10000 (업비트)
if (field.equals("TRIX_VALUE")) {
int len = intP(p, "length", 12);
EMAIndicator e3 = tripleLogEma(close, len);
PreviousValueIndicator prev3 = new PreviousValueIndicator(e3);
return NumericIndicator.of(e3).minus(prev3).multipliedBy(10_000);
}
if (field.equals("TRIX_SIGNAL")) {
int len = intP(p, "length", 12);
EMAIndicator e3 = tripleLogEma(close, len);
PreviousValueIndicator prev3 = new PreviousValueIndicator(e3);
NumericIndicator trix = NumericIndicator.of(e3).minus(prev3).multipliedBy(10_000);
return new EMAIndicator(trix, intP(p, "signalLength", 9));
}
// OBV
if (field.equals("OBV_LINE")) return new OnBalanceVolumeIndicator(s);
if (field.equals("OBV_SIGNAL")) {
OnBalanceVolumeIndicator obv = new OnBalanceVolumeIndicator(s);
String maType = p != null ? p.getOrDefault("maType", "SMA").toString() : "SMA";
int maLen = intP(p, "maLength", 9);
return switch (maType) {
case "EMA" -> new EMAIndicator(obv, maLen);
case "WMA" -> new WMAIndicator(obv, maLen);
default -> new SMAIndicator(obv, maLen);
};
}
// VR
if (field.equals("VR_VALUE") || indType.equals("VR"))
return vrIndicator(s, intP(p, "length", 10));
// 이격도 DISPARITY5 → period 5
if (field.startsWith("DISPARITY")) {
int period = parseTrailingInt(field, "DISPARITY", 20);
return disparityIndicator(s, period, p);
}
if (field.equals("PSY_VALUE") || field.equals("NEW_PSY_VALUE")
|| indType.equals("PSYCHOLOGICAL") || indType.equals("NEW_PSYCHOLOGICAL"))
return newPsychIndicator(s, intP(p, "length", 12), false);
if (field.equals("INVEST_PSY_VALUE") || indType.equals("INVEST_PSYCHOLOGICAL"))
return newPsychIndicator(s, intP(p, "length", 10), true);
// Williams %R
if (field.equals("WILLIAMS_R_VALUE")) return new WilliamsRIndicator(s, intP(p, "length", 14));
// Bollinger Bands
if (field.equals("UPPER_BAND") || field.equals("LOWER_BAND") || field.equals("MIDDLE_BAND")) {
int len = intP(p, "length", 20);
double mult = dblP(p, "mult", 2.0);
String maType = p.getOrDefault("maType", "SMA").toString();
Indicator<Num> basis = maOfSource(close, s, maType, len);
StandardDeviationIndicator sd = StandardDeviationIndicator.ofSample(close, len);
BollingerBandsMiddleIndicator mid = new BollingerBandsMiddleIndicator(basis);
Num k = s.numFactory().numOf(mult);
return switch (field) {
case "UPPER_BAND" -> new BollingerBandsUpperIndicator(mid, sd, k);
case "LOWER_BAND" -> new BollingerBandsLowerIndicator(mid, sd, k);
default -> mid;
};
}
// MA (SMA)
if (field.startsWith("MA") && !field.startsWith("MACD")) {
try { return new SMAIndicator(close, Integer.parseInt(field.substring(2))); }
catch (NumberFormatException ignored) { /* fall */ }
}
if (field.startsWith("EMA")) {
try { return new EMAIndicator(close, Integer.parseInt(field.substring(3))); }
catch (NumberFormatException ignored) { /* fall */ }
}
// 일목균형표
if (field.equals("CONVERSION_LINE")) return ichimokuTenkan(s, p);
if (field.equals("BASE_LINE")) return ichimokuKijun(s, p);
if (field.equals("LEADING_SPAN1")) return ichimokuSpanA(s, p);
if (field.equals("LEADING_SPAN2")) return ichimokuSpanB(s, p);
if (field.equals("LAGGING_SPAN")) return ichimokuLagging(s, p);
// 거래량 MA
if (field.equals("VOLUME_MA")) return new SMAIndicator(new VolumeIndicator(s, 1), intP(p, "length", 20));
log.warn("[Adapter] 미지원 필드: {} (indicatorType={})", field, indType);
return new ClosePriceIndicator(s);
}
// ── 일목균형표 지표 빌더 ──────────────────────────────────────────────────
private IchimokuTenkanSenIndicator ichimokuTenkan(BarSeries s, Map<String, Object> p) {
return new IchimokuTenkanSenIndicator(s, intP(p, "conversionPeriods", 9));
}
private IchimokuKijunSenIndicator ichimokuKijun(BarSeries s, Map<String, Object> p) {
return new IchimokuKijunSenIndicator(s, intP(p, "basePeriods", 26));
}
private IchimokuSenkouSpanAIndicator ichimokuSpanA(BarSeries s, Map<String, Object> p) {
return new IchimokuSenkouSpanAIndicator(s, ichimokuTenkan(s, p), ichimokuKijun(s, p),
intP(p, "displacement", 26));
}
private IchimokuSenkouSpanBIndicator ichimokuSpanB(BarSeries s, Map<String, Object> p) {
// IndicatorService 와 동일하게 displacement 파라미터 반영
return new IchimokuSenkouSpanBIndicator(s, intP(p, "laggingSpan2Periods", 52),
intP(p, "displacement", 26));
}
private IchimokuChikouSpanIndicator ichimokuLagging(BarSeries s, Map<String, Object> p) {
return new IchimokuChikouSpanIndicator(s, intP(p, "displacement", 26));
}
/** 종가가 구름 위 */
private Rule buildCloudAboveRule(BarSeries s, Map<String, Object> p) {
NumericIndicator cloudTop = NumericIndicator.of(ichimokuSpanA(s, p)).max(ichimokuSpanB(s, p));
return new OverIndicatorRule(new ClosePriceIndicator(s), cloudTop);
}
/** 종가가 구름 아래 */
private Rule buildCloudBelowRule(BarSeries s, Map<String, Object> p) {
NumericIndicator cloudBottom = NumericIndicator.of(ichimokuSpanA(s, p)).min(ichimokuSpanB(s, p));
return new UnderIndicatorRule(new ClosePriceIndicator(s), cloudBottom);
}
/** 종가가 구름 안 */
private Rule buildInCloudRule(BarSeries s, Map<String, Object> p) {
ClosePriceIndicator close = new ClosePriceIndicator(s);
NumericIndicator cloudTop = NumericIndicator.of(ichimokuSpanA(s, p)).max(ichimokuSpanB(s, p));
NumericIndicator cloudBottom = NumericIndicator.of(ichimokuSpanA(s, p)).min(ichimokuSpanB(s, p));
return new AndRule(
new OrRule(new OverIndicatorRule(close, cloudBottom),
buildEqRule(close, cloudBottom, s)),
new OrRule(new UnderIndicatorRule(close, cloudTop),
buildEqRule(close, cloudTop, s)));
}
/** 구름 상향(true)/하향(false) 돌파 */
private Rule buildCloudBreakRule(BarSeries s, Map<String, Object> p, boolean up) {
NumericIndicator cloudTop = NumericIndicator.of(ichimokuSpanA(s, p)).max(ichimokuSpanB(s, p));
NumericIndicator cloudBottom = NumericIndicator.of(ichimokuSpanA(s, p)).min(ichimokuSpanB(s, p));
ClosePriceIndicator close = new ClosePriceIndicator(s);
if (up) return new CrossedUpIndicatorRule(close, cloudTop);
else return new CrossedDownIndicatorRule(close, cloudBottom);
}
// ── 범용 Rule 빌더 ────────────────────────────────────────────────────────
/** |a - b| < epsilon → equal */
private Rule buildEqRule(Indicator<Num> a, Indicator<Num> b, BarSeries series) {
NumericIndicator diff = NumericIndicator.of(a).minus(b).abs();
return new UnderIndicatorRule(diff,
new ConstantIndicator<>(series, series.numFactory().numOf(1e-9)));
}
/** N봉 연속으로 inner 규칙이 성립 */
private Rule buildHoldRule(Rule inner, int n) {
if (n <= 1) return inner;
return new Rule() {
@Override
public boolean isSatisfied(int index, TradingRecord record) {
if (index < n - 1) return false;
for (int i = index - n + 1; i <= index; i++) {
if (!inner.isSatisfied(i, record)) return false;
}
return true;
}
};
}
// ── TRIX 헬퍼 ─────────────────────────────────────────────────────────────
private EMAIndicator tripleLogEma(ClosePriceIndicator close, int len) {
Indicator<Num> logClose = UnaryOperationIndicator.log(close);
return new EMAIndicator(new EMAIndicator(new EMAIndicator(logClose, len), len), len);
}
private Indicator<Num> maOfSource(Indicator<Num> source, BarSeries s, String maType, int len) {
return switch (maType) {
case "EMA" -> new EMAIndicator(source, len);
case "WMA" -> new WMAIndicator(source, len);
case "SMMA (RMA)", "RMA" -> new WildersMAIndicator(source, len);
default -> new SMAIndicator(source, len);
};
}
private int parseTrailingInt(String field, String prefix, int def) {
if (!field.startsWith(prefix)) return def;
try {
return Integer.parseInt(field.substring(prefix.length()));
} catch (NumberFormatException e) {
return def;
}
}
/** 이격도 = 종가 / SMA(period) * 100 */
private Indicator<Num> disparityIndicator(BarSeries s, int period, Map<String, Object> p) {
ClosePriceIndicator close = new ClosePriceIndicator(s);
SMAIndicator sma = new SMAIndicator(close, period);
return NumericIndicator.of(close).dividedBy(sma).multipliedBy(100);
}
private Indicator<Num> vrIndicator(BarSeries s, int period) {
return new VrIndicator(s, period);
}
private Indicator<Num> newPsychIndicator(BarSeries s, int period, boolean volumeWeighted) {
return new PsychologicalIndicator(s, period, volumeWeighted);
}
/** VR(거래량비율) = 상승일 거래량합 / 하락일 거래량합 × 100 */
private static final class VrIndicator extends CachedIndicator<Num> {
private final int period;
private final ClosePriceIndicator close;
private final VolumeIndicator volume;
VrIndicator(BarSeries series, int period) {
super(series);
this.period = period;
this.close = new ClosePriceIndicator(series);
this.volume = new VolumeIndicator(series, 1);
}
@Override
protected Num calculate(int index) {
if (index < period) return null;
double upVol = 0, downVol = 0;
for (int j = index - period + 1; j <= index; j++) {
Num c = close.getValue(j);
Num cPrev = close.getValue(j - 1);
Num v = volume.getValue(j);
if (c == null || cPrev == null || v == null) continue;
if (c.isGreaterThan(cPrev)) upVol += v.doubleValue();
else if (c.isLessThan(cPrev)) downVol += v.doubleValue();
}
if (downVol == 0) return null;
return getBarSeries().numFactory().numOf(upVol / downVol * 100.0);
}
@Override
public int getCountOfUnstableBars() {
return period + 1;
}
}
/** 신심리도 / 투자심리도 */
private static final class PsychologicalIndicator extends CachedIndicator<Num> {
private final int period;
private final boolean volumeWeighted;
private final ClosePriceIndicator close;
private final VolumeIndicator volume;
PsychologicalIndicator(BarSeries series, int period, boolean volumeWeighted) {
super(series);
this.period = period;
this.volumeWeighted = volumeWeighted;
this.close = new ClosePriceIndicator(series);
this.volume = new VolumeIndicator(series, 1);
}
@Override
protected Num calculate(int index) {
if (index < period) return null;
if (!volumeWeighted) {
int up = 0;
for (int j = index - period + 1; j <= index; j++) {
Num c = close.getValue(j);
Num cPrev = close.getValue(j - 1);
if (c != null && cPrev != null && c.isGreaterThan(cPrev)) up++;
}
return getBarSeries().numFactory().numOf((double) up / period * 100.0);
}
double upVol = 0, total = 0;
for (int j = index - period + 1; j <= index; j++) {
Num c = close.getValue(j);
Num cPrev = close.getValue(j - 1);
Num v = volume.getValue(j);
if (v == null) continue;
total += v.doubleValue();
if (c != null && cPrev != null && c.isGreaterThan(cPrev)) upVol += v.doubleValue();
}
if (total == 0) return null;
return getBarSeries().numFactory().numOf(upVol / total * 100.0);
}
@Override
public int getCountOfUnstableBars() {
return period + 1;
}
}
// ── 파라미터 헬퍼 ─────────────────────────────────────────────────────────
private int intP(Map<String, Object> p, String k, int def) {
if (p == null) return def;
Object v = p.get(k);
return v == null ? def : ((Number) v).intValue();
}
private double dblP(Map<String, Object> p, String k, double def) {
if (p == null) return def;
Object v = p.get(k);
return v == null ? def : ((Number) v).doubleValue();
}
}
@@ -0,0 +1,94 @@
package com.goldenchart.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goldenchart.dto.StrategyDto;
import com.goldenchart.entity.GcStrategy;
import com.goldenchart.repository.GcStrategyRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Slf4j
public class StrategyService {
private final GcStrategyRepository repository;
private final ObjectMapper objectMapper;
// ── 조회 ──────────────────────────────────────────────────────────────────
public List<StrategyDto> list(Long userId, String deviceId) {
List<GcStrategy> entities = userId != null
? repository.findByUserIdOrderByUpdatedAtDesc(userId)
: repository.findByDeviceIdOrderByUpdatedAtDesc(deviceId != null ? deviceId : "");
return entities.stream().map(this::toDto).toList();
}
public Optional<StrategyDto> findById(Long id) {
return repository.findById(id).map(this::toDto);
}
// ── 저장 / 수정 ───────────────────────────────────────────────────────────
@Transactional
public StrategyDto save(StrategyDto dto, Long userId, String deviceId) {
GcStrategy entity;
if (dto.getId() != null) {
entity = repository.findById(dto.getId()).orElseGet(GcStrategy::new);
} else {
entity = new GcStrategy();
entity.setUserId(userId);
entity.setDeviceId(deviceId);
}
entity.setName(dto.getName() != null ? dto.getName() : "전략");
entity.setDescription(dto.getDescription());
if (dto.getEnabled() != null) entity.setEnabled(dto.getEnabled());
else if (entity.getEnabled() == null) entity.setEnabled(true);
try {
entity.setBuyConditionJson(
dto.getBuyCondition() != null ? objectMapper.writeValueAsString(dto.getBuyCondition()) : null);
entity.setSellConditionJson(
dto.getSellCondition() != null ? objectMapper.writeValueAsString(dto.getSellCondition()) : null);
} catch (Exception e) {
log.warn("전략 DSL 직렬화 실패: {}", e.getMessage());
}
return toDto(repository.save(entity));
}
// ── 삭제 ──────────────────────────────────────────────────────────────────
@Transactional
public void delete(Long id) {
repository.deleteById(id);
}
// ── 변환 ──────────────────────────────────────────────────────────────────
private StrategyDto toDto(GcStrategy e) {
StrategyDto dto = new StrategyDto();
dto.setId(e.getId());
dto.setName(e.getName());
dto.setDescription(e.getDescription());
dto.setEnabled(e.getEnabled());
dto.setCreatedAt(e.getCreatedAt());
dto.setUpdatedAt(e.getUpdatedAt());
try {
if (e.getBuyConditionJson() != null)
dto.setBuyCondition(objectMapper.readTree(e.getBuyConditionJson()));
if (e.getSellConditionJson() != null)
dto.setSellCondition(objectMapper.readTree(e.getSellConditionJson()));
} catch (Exception ex) {
log.warn("전략 DSL 역직렬화 실패: {}", ex.getMessage());
}
return dto;
}
}
@@ -0,0 +1,91 @@
package com.goldenchart.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.ta4j.core.Rule;
import org.ta4j.core.Strategy;
import org.ta4j.core.TradingRecord;
/**
* 포지션 종속성 선택에 따른 매매 시그널 판정 코어 컴포넌트.
*
* <h3>모드 설명</h3>
* <ul>
* <li><b>LONG_ONLY</b> — Ta4j 표준 엄격 모드.
* {@code strategy.shouldEnter/shouldExit} 를 사용하므로,
* TradingRecord 에 열린 포지션이 없으면 매도 시그널을 내보내지 않는다.</li>
* <li><b>SIGNAL_ONLY</b> — 포지션 락 우회 모드.
* 내부 Rule 인스턴스를 직접 호출({@code rule.isSatisfied(index)})하므로,
* 이전 매수 기록 여부와 무관하게 지표 수식만 충족되면 시그널을 확정한다.</li>
* </ul>
*/
@Component
@Slf4j
public class StrategySignalDeterminer {
/**
* positionMode 에 따라 포지션 제약 유무를 분기하여 시그널을 결정한다.
*
* @param strategy Ta4j Strategy (entryRule / exitRule 포함)
* @param tradingRecord 현재 TradingRecord 상태 (LONG_ONLY 모드에서 참조)
* @param index BarSeries 인덱스
* @param positionMode "LONG_ONLY" | "SIGNAL_ONLY" (null → LONG_ONLY 기본 적용)
* @return "BUY" | "SELL" | "NONE"
*/
public String determineSignal(Strategy strategy, TradingRecord tradingRecord,
int index, String positionMode) {
try {
if ("SIGNAL_ONLY".equals(positionMode)) {
return determineSignalOnly(strategy, index);
}
// LONG_ONLY (기본)
return determineLongOnly(strategy, tradingRecord, index);
} catch (Exception e) {
log.warn("[SignalDeterminer] 시그널 판정 오류 idx={} mode={}: {}", index, positionMode, e.getMessage());
return "NONE";
}
}
// ── 보조 오버로드: Rule[] 직접 전달 (LiveStrategyEvaluator 호환) ─────────────
/**
* Rule 배열을 직접 받아 positionMode 에 따라 시그널 판정.
* entryRule = rules[0], exitRule = rules[1]
*/
public String determineSignalFromRules(Rule entryRule, Rule exitRule,
TradingRecord tradingRecord,
int index, String positionMode) {
try {
if ("SIGNAL_ONLY".equals(positionMode)) {
boolean enterOk = entryRule != null && entryRule.isSatisfied(index);
boolean exitOk = exitRule != null && exitRule.isSatisfied(index);
if (enterOk) return "BUY";
if (exitOk) return "SELL";
return "NONE";
}
// LONG_ONLY — tradingRecord 를 이용한 엄격 모드
boolean enterOk = entryRule != null && entryRule.isSatisfied(index, tradingRecord);
boolean exitOk = exitRule != null && exitRule.isSatisfied(index, tradingRecord);
if (enterOk) return "BUY";
if (exitOk) return "SELL";
return "NONE";
} catch (Exception e) {
log.warn("[SignalDeterminer] Rule 판정 오류 idx={} mode={}: {}", index, positionMode, e.getMessage());
return "NONE";
}
}
// ── Private ───────────────────────────────────────────────────────────────
private String determineLongOnly(Strategy strategy, TradingRecord record, int index) {
if (strategy.shouldEnter(index, record)) return "BUY";
if (strategy.shouldExit(index, record)) return "SELL";
return "NONE";
}
private String determineSignalOnly(Strategy strategy, int index) {
if (strategy.getEntryRule().isSatisfied(index)) return "BUY";
if (strategy.getExitRule().isSatisfied(index)) return "SELL";
return "NONE";
}
}
@@ -0,0 +1,88 @@
package com.goldenchart.service;
import com.goldenchart.dto.TradeSignalDto;
import com.goldenchart.entity.GcTradeSignal;
import com.goldenchart.repository.GcTradeSignalRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 매매 시그널 이력 서비스.
* 실시간 전략 체크(LiveStrategyEvaluator / LiveStrategyScheduler)에서
* BUY/SELL 시그널이 발생하면 이 서비스를 통해 DB에 저장한다.
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class TradeSignalService {
private final GcTradeSignalRepository repo;
private final FcmPushService fcmPushService;
// ── 저장 ─────────────────────────────────────────────────────────────────
public TradeSignalDto save(String deviceId, Long userId, String market,
Long strategyId, String strategyName,
String signalType, double price,
long candleTime, String candleType,
String executionType) {
GcTradeSignal entity = GcTradeSignal.builder()
.deviceId(deviceId)
.userId(userId)
.market(market)
.strategyId(strategyId)
.strategyName(strategyName)
.signalType(signalType)
.price(price)
.candleTime(candleTime)
.candleType(candleType)
.executionType(executionType)
.build();
GcTradeSignal saved = repo.save(entity);
log.info("[TradeSignal] saved {} {} @ {} market={}", signalType, candleType, price, market);
if (deviceId != null) {
fcmPushService.sendTradeSignalIfEnabled(
deviceId, userId, market, signalType, price, strategyName, saved.getId());
}
return toDto(saved);
}
// ── 조회 ─────────────────────────────────────────────────────────────────
@Transactional(readOnly = true)
public List<TradeSignalDto> list(Long userId, String deviceId) {
List<GcTradeSignal> list = userId != null
? repo.findByUserIdOrderByCreatedAtDesc(userId)
: repo.findByDeviceIdOrderByCreatedAtDesc(deviceId != null ? deviceId : "");
return list.stream().map(this::toDto).toList();
}
@Transactional(readOnly = true)
public List<TradeSignalDto> listByMarket(Long userId, String deviceId, String market) {
return repo.findByDeviceIdAndMarketOrderByCreatedAtDesc(
deviceId != null ? deviceId : "", market)
.stream().map(this::toDto).toList();
}
// ── 변환 ─────────────────────────────────────────────────────────────────
private TradeSignalDto toDto(GcTradeSignal e) {
return TradeSignalDto.builder()
.id(e.getId())
.market(e.getMarket())
.strategyId(e.getStrategyId())
.strategyName(e.getStrategyName())
.signalType(e.getSignalType())
.price(e.getPrice())
.candleTime(e.getCandleTime())
.candleType(e.getCandleType())
.executionType(e.getExecutionType())
.createdAt(e.getCreatedAt() != null ? e.getCreatedAt().toString() : null)
.build();
}
}
@@ -0,0 +1,80 @@
package com.goldenchart.service;
import com.goldenchart.entity.GcWatchlist;
import com.goldenchart.repository.GcWatchlistRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
/**
* 관심종목(DB) 관리 + 실시간 전략 체크 대상 자동 연동.
*
* <p>별표로 관심 등록 시 {@code gc_watchlist} 저장과 동시에
* 해당 종목을 실시간 전략 체크 대상({@code gc_live_strategy_settings})으로 등록한다.
* 해제 시 전략 체크도 함께 비활성화한다.
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class WatchlistService {
private final GcWatchlistRepository watchlistRepo;
private final LiveStrategySettingsService liveStrategySettingsService;
@Transactional(readOnly = true)
public List<GcWatchlist> list(Long userId, String deviceId) {
if (userId != null) return watchlistRepo.findByUserIdOrderByDisplayOrderAsc(userId);
return watchlistRepo.findByDeviceIdOrderByDisplayOrderAsc(deviceId != null ? deviceId : "anonymous");
}
public GcWatchlist add(Long userId, String deviceId, String symbol,
String koreanName, String englishName, Integer displayOrder) {
if (symbol == null || symbol.isBlank()) {
throw new IllegalArgumentException("symbol is required");
}
Optional<GcWatchlist> existing = findOne(userId, deviceId, symbol);
if (existing.isPresent()) {
liveStrategySettingsService.enableForWatchlistMarket(userId, deviceId, symbol);
return existing.get();
}
int order = displayOrder != null ? displayOrder : nextOrder(userId, deviceId);
GcWatchlist item = GcWatchlist.builder()
.userId(userId)
.deviceId(userId == null ? deviceId : null)
.symbol(symbol)
.koreanName(koreanName)
.englishName(englishName)
.displayOrder(order)
.build();
watchlistRepo.save(item);
liveStrategySettingsService.enableForWatchlistMarket(userId, deviceId, symbol);
log.info("[Watchlist] added {} → live strategy enabled", symbol);
return item;
}
public void remove(Long userId, String deviceId, String symbol) {
if (userId != null) watchlistRepo.deleteByUserIdAndSymbol(userId, symbol);
else watchlistRepo.deleteByDeviceIdAndSymbol(deviceId != null ? deviceId : "anonymous", symbol);
liveStrategySettingsService.disableForWatchlistMarket(userId, deviceId, symbol);
log.info("[Watchlist] removed {} → live strategy disabled", symbol);
}
private Optional<GcWatchlist> findOne(Long userId, String deviceId, String symbol) {
return list(userId, deviceId).stream()
.filter(w -> symbol.equals(w.getSymbol()))
.findFirst();
}
private int nextOrder(Long userId, String deviceId) {
List<GcWatchlist> list = list(userId, deviceId);
return list.isEmpty() ? 0 : list.get(list.size() - 1).getDisplayOrder() + 1;
}
}