goldenChat base source add
This commit is contained in:
@@ -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<indicatorType, Map<paramKey, paramValue>>
|
||||
*/
|
||||
@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<indicatorType, Map<paramKey, paramValue>>
|
||||
*/
|
||||
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<indicatorType, Map{plots, hlines}>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user