모의투자 관리 기능 추가
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
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.GcPaperSymbolAllocation;
|
||||
import com.goldenchart.repository.GcPaperPositionRepository;
|
||||
import com.goldenchart.repository.GcPaperSymbolAllocationRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional
|
||||
public class PaperAllocationService {
|
||||
|
||||
private static final RoundingMode RM = RoundingMode.HALF_UP;
|
||||
|
||||
private final GcPaperSymbolAllocationRepository allocRepo;
|
||||
private final GcPaperPositionRepository positionRepo;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<PaperAllocationDto> listWithMetrics(Long accountId, Map<String, Double> markPrices,
|
||||
double totalAsset) {
|
||||
List<GcPaperSymbolAllocation> rows = allocRepo.findByAccountIdOrderBySortOrderAscSymbolAsc(accountId);
|
||||
List<PaperAllocationDto> out = new ArrayList<>();
|
||||
for (GcPaperSymbolAllocation a : rows) {
|
||||
out.add(toDto(a, accountId, markPrices, totalAsset));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public List<PaperAllocationDto> bulkUpsert(Long accountId, PaperAllocationBulkRequest req,
|
||||
BigDecimal defaultMaxKrw) {
|
||||
if (req == null || req.getItems() == null) return listWithMetrics(accountId, null, 0);
|
||||
int order = 0;
|
||||
for (PaperAllocationBulkRequest.PaperAllocationItem item : req.getItems()) {
|
||||
if (item.getSymbol() == null || item.getSymbol().isBlank()) continue;
|
||||
GcPaperSymbolAllocation row = allocRepo.findByAccountIdAndSymbol(accountId, item.getSymbol())
|
||||
.orElse(GcPaperSymbolAllocation.builder()
|
||||
.accountId(accountId)
|
||||
.symbol(item.getSymbol())
|
||||
.build());
|
||||
if (item.getKoreanName() != null) row.setKoreanName(item.getKoreanName());
|
||||
if (item.getMaxInvestKrw() != null) {
|
||||
row.setMaxInvestKrw(BigDecimal.valueOf(item.getMaxInvestKrw()).setScale(2, RM));
|
||||
} else if (row.getMaxInvestKrw() == null || row.getMaxInvestKrw().signum() == 0) {
|
||||
row.setMaxInvestKrw(defaultMaxKrw);
|
||||
}
|
||||
if (item.getBudgetPct() != null) row.setBudgetPct(BigDecimal.valueOf(item.getBudgetPct()));
|
||||
if (item.getStrategyId() != null) row.setStrategyId(item.getStrategyId());
|
||||
if (item.getIsActive() != null) row.setIsActive(item.getIsActive());
|
||||
if (item.getPinned() != null) row.setPinned(item.getPinned());
|
||||
row.setSortOrder(item.getSortOrder() != null ? item.getSortOrder() : order++);
|
||||
allocRepo.save(row);
|
||||
}
|
||||
return listWithMetrics(accountId, null, 0);
|
||||
}
|
||||
|
||||
public PaperAllocationDto patch(Long accountId, String symbol, PaperAllocationPatchRequest req) {
|
||||
GcPaperSymbolAllocation row = allocRepo.findByAccountIdAndSymbol(accountId, symbol)
|
||||
.orElseThrow(() -> new IllegalArgumentException("종목 한도 설정이 없습니다: " + symbol));
|
||||
if (req.getMaxInvestKrw() != null) {
|
||||
row.setMaxInvestKrw(BigDecimal.valueOf(req.getMaxInvestKrw()).setScale(2, RM));
|
||||
}
|
||||
if (req.getBudgetPct() != null) row.setBudgetPct(BigDecimal.valueOf(req.getBudgetPct()));
|
||||
if (req.getStrategyId() != null) row.setStrategyId(req.getStrategyId());
|
||||
if (req.getIsActive() != null) row.setIsActive(req.getIsActive());
|
||||
if (req.getPinned() != null) row.setPinned(req.getPinned());
|
||||
if (req.getSortOrder() != null) row.setSortOrder(req.getSortOrder());
|
||||
if (req.getKoreanName() != null) row.setKoreanName(req.getKoreanName());
|
||||
allocRepo.save(row);
|
||||
return toDto(row, accountId, null, 0);
|
||||
}
|
||||
|
||||
public GcPaperSymbolAllocation getOrDefault(Long accountId, String symbol, BigDecimal defaultMax) {
|
||||
return allocRepo.findByAccountIdAndSymbol(accountId, symbol)
|
||||
.orElseGet(() -> {
|
||||
GcPaperSymbolAllocation a = GcPaperSymbolAllocation.builder()
|
||||
.accountId(accountId)
|
||||
.symbol(symbol)
|
||||
.maxInvestKrw(defaultMax)
|
||||
.isActive(true)
|
||||
.build();
|
||||
return allocRepo.save(a);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 매수 가능 금액 검증. autoTrade=true 시 전역 auto budget % 적용.
|
||||
*/
|
||||
public void validateBuy(GcPaperAccount account, GcAppSettings app, String symbol,
|
||||
BigDecimal needKrw, boolean autoTrade) {
|
||||
double reserved = account.getReservedCash() != null
|
||||
? account.getReservedCash().doubleValue() : 0;
|
||||
double orderable = account.getCashBalance().doubleValue() - reserved;
|
||||
if (autoTrade) {
|
||||
double pct = app.getPaperAutoTradeBudgetPct() != null
|
||||
? app.getPaperAutoTradeBudgetPct().doubleValue() : 95;
|
||||
orderable = orderable * pct / 100.0;
|
||||
}
|
||||
if (needKrw.doubleValue() > orderable + 0.01) {
|
||||
throw new IllegalStateException("주문가능 현금이 부족합니다.");
|
||||
}
|
||||
|
||||
BigDecimal defaultMax = account.getInitialCapitalSnapshot() != null
|
||||
? account.getInitialCapitalSnapshot()
|
||||
: (app.getPaperInitialCapital() != null
|
||||
? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000));
|
||||
GcPaperSymbolAllocation alloc = getOrDefault(account.getId(), symbol, defaultMax);
|
||||
if (!Boolean.TRUE.equals(alloc.getIsActive())) {
|
||||
throw new IllegalStateException("해당 종목은 신규 매수가 비활성화되어 있습니다.");
|
||||
}
|
||||
double invested = investedKrw(account.getId(), symbol, null);
|
||||
double max = alloc.getMaxInvestKrw().doubleValue();
|
||||
double available = Math.max(0, max - invested);
|
||||
if (needKrw.doubleValue() > available + 0.01) {
|
||||
throw new IllegalStateException("종목 투자 한도를 초과합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
public double investedKrw(Long accountId, String symbol, Map<String, Double> markPrices) {
|
||||
Optional<GcPaperPosition> pos = positionRepo.findByAccountIdAndSymbol(accountId, symbol);
|
||||
if (pos.isEmpty() || pos.get().getQuantity().doubleValue() <= 0) return 0;
|
||||
GcPaperPosition p = pos.get();
|
||||
double qty = p.getQuantity().doubleValue();
|
||||
Double mark = markPrices != null ? markPrices.get(symbol) : null;
|
||||
if (mark != null && mark > 0) return mark * qty;
|
||||
return p.getAvgPrice().doubleValue() * qty;
|
||||
}
|
||||
|
||||
public void deleteAllForAccount(Long accountId) {
|
||||
allocRepo.deleteByAccountId(accountId);
|
||||
}
|
||||
|
||||
public void seedFromPositions(Long accountId, BigDecimal defaultMax) {
|
||||
List<GcPaperPosition> positions = positionRepo.findByAccountIdOrderBySymbolAsc(accountId);
|
||||
int sortOrder = 0;
|
||||
for (GcPaperPosition p : positions) {
|
||||
if (p.getQuantity().doubleValue() <= 0) continue;
|
||||
final int order = sortOrder++;
|
||||
allocRepo.findByAccountIdAndSymbol(accountId, p.getSymbol()).orElseGet(() ->
|
||||
allocRepo.save(GcPaperSymbolAllocation.builder()
|
||||
.accountId(accountId)
|
||||
.symbol(p.getSymbol())
|
||||
.koreanName(p.getKoreanName())
|
||||
.maxInvestKrw(defaultMax)
|
||||
.sortOrder(order)
|
||||
.build()));
|
||||
}
|
||||
}
|
||||
|
||||
private PaperAllocationDto toDto(GcPaperSymbolAllocation a, Long accountId,
|
||||
Map<String, Double> markPrices, double totalAsset) {
|
||||
double invested = investedKrw(accountId, a.getSymbol(), markPrices);
|
||||
double max = a.getMaxInvestKrw().doubleValue();
|
||||
double available = Math.max(0, max - invested);
|
||||
double eval = invested;
|
||||
double cost = 0;
|
||||
Optional<GcPaperPosition> pos = positionRepo.findByAccountIdAndSymbol(accountId, a.getSymbol());
|
||||
if (pos.isPresent() && pos.get().getQuantity().doubleValue() > 0) {
|
||||
double qty = pos.get().getQuantity().doubleValue();
|
||||
double avg = pos.get().getAvgPrice().doubleValue();
|
||||
cost = avg * qty;
|
||||
Double mark = markPrices != null ? markPrices.get(a.getSymbol()) : null;
|
||||
eval = mark != null ? mark * qty : cost;
|
||||
}
|
||||
double retPct = cost > 0 ? (eval - cost) / cost * 100 : 0;
|
||||
double weight = totalAsset > 0 ? eval / totalAsset * 100 : 0;
|
||||
return PaperAllocationDto.builder()
|
||||
.id(a.getId())
|
||||
.symbol(a.getSymbol())
|
||||
.koreanName(a.getKoreanName())
|
||||
.maxInvestKrw(max)
|
||||
.budgetPct(a.getBudgetPct() != null ? a.getBudgetPct().doubleValue() : null)
|
||||
.strategyId(a.getStrategyId())
|
||||
.isActive(a.getIsActive())
|
||||
.pinned(a.getPinned())
|
||||
.sortOrder(a.getSortOrder())
|
||||
.investedKrw(invested)
|
||||
.availableBuyKrw(available)
|
||||
.evalAmount(eval)
|
||||
.symbolReturnPct(retPct)
|
||||
.weightPct(weight)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.goldenchart.dto.PaperLedgerEntryDto;
|
||||
import com.goldenchart.dto.PaperPageDto;
|
||||
import com.goldenchart.entity.GcPaperAccount;
|
||||
import com.goldenchart.entity.GcPaperCashLedger;
|
||||
import com.goldenchart.entity.GcPaperTrade;
|
||||
import com.goldenchart.repository.GcPaperCashLedgerRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional
|
||||
public class PaperLedgerService {
|
||||
|
||||
public static final String INITIAL = "INITIAL";
|
||||
public static final String BUY_SETTLE = "BUY_SETTLE";
|
||||
public static final String SELL_SETTLE = "SELL_SETTLE";
|
||||
public static final String RESET = "RESET";
|
||||
|
||||
private final GcPaperCashLedgerRepository ledgerRepo;
|
||||
|
||||
public void recordInitial(GcPaperAccount account, BigDecimal initialCash) {
|
||||
ledgerRepo.save(GcPaperCashLedger.builder()
|
||||
.accountId(account.getId())
|
||||
.entryType(INITIAL)
|
||||
.amount(initialCash)
|
||||
.cashBefore(BigDecimal.ZERO)
|
||||
.cashAfter(initialCash)
|
||||
.build());
|
||||
}
|
||||
|
||||
public void recordBuySettle(GcPaperAccount account, GcPaperTrade trade, BigDecimal totalCost) {
|
||||
BigDecimal before = account.getCashBalance().add(totalCost);
|
||||
ledgerRepo.save(GcPaperCashLedger.builder()
|
||||
.accountId(account.getId())
|
||||
.entryType(BUY_SETTLE)
|
||||
.amount(totalCost.negate())
|
||||
.cashBefore(before)
|
||||
.cashAfter(account.getCashBalance())
|
||||
.refTradeId(trade.getId())
|
||||
.symbol(trade.getSymbol())
|
||||
.build());
|
||||
}
|
||||
|
||||
public void recordSellSettle(GcPaperAccount account, GcPaperTrade trade, BigDecimal netProceeds) {
|
||||
BigDecimal before = account.getCashBalance().subtract(netProceeds);
|
||||
ledgerRepo.save(GcPaperCashLedger.builder()
|
||||
.accountId(account.getId())
|
||||
.entryType(SELL_SETTLE)
|
||||
.amount(netProceeds)
|
||||
.cashBefore(before)
|
||||
.cashAfter(account.getCashBalance())
|
||||
.refTradeId(trade.getId())
|
||||
.symbol(trade.getSymbol())
|
||||
.build());
|
||||
}
|
||||
|
||||
public void recordReset(GcPaperAccount account, BigDecimal cashBefore, BigDecimal newCash) {
|
||||
ledgerRepo.save(GcPaperCashLedger.builder()
|
||||
.accountId(account.getId())
|
||||
.entryType(RESET)
|
||||
.amount(newCash.subtract(cashBefore))
|
||||
.cashBefore(cashBefore)
|
||||
.cashAfter(newCash)
|
||||
.build());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PaperPageDto<PaperLedgerEntryDto> list(Long accountId, LocalDateTime from, LocalDateTime to,
|
||||
int page, int size) {
|
||||
PageRequest pr = PageRequest.of(Math.max(0, page), Math.min(100, Math.max(1, size)));
|
||||
Page<GcPaperCashLedger> result = (from != null || to != null)
|
||||
? ledgerRepo.findByAccountIdAndCreatedAtBetweenOrderByCreatedAtDesc(
|
||||
accountId,
|
||||
from != null ? from : LocalDateTime.of(2000, 1, 1, 0, 0),
|
||||
to != null ? to : LocalDateTime.now().plusYears(10),
|
||||
pr)
|
||||
: ledgerRepo.findByAccountIdOrderByCreatedAtDesc(accountId, pr);
|
||||
return PaperPageDto.<PaperLedgerEntryDto>builder()
|
||||
.content(result.getContent().stream().map(this::toDto).toList())
|
||||
.page(result.getNumber())
|
||||
.size(result.getSize())
|
||||
.totalElements(result.getTotalElements())
|
||||
.totalPages(result.getTotalPages())
|
||||
.build();
|
||||
}
|
||||
|
||||
private PaperLedgerEntryDto toDto(GcPaperCashLedger e) {
|
||||
return PaperLedgerEntryDto.builder()
|
||||
.id(e.getId())
|
||||
.entryType(e.getEntryType())
|
||||
.amount(e.getAmount().doubleValue())
|
||||
.cashBefore(e.getCashBefore().doubleValue())
|
||||
.cashAfter(e.getCashAfter().doubleValue())
|
||||
.refTradeId(e.getRefTradeId())
|
||||
.symbol(e.getSymbol())
|
||||
.createdAt(e.getCreatedAt() != null ? e.getCreatedAt().toString() : null)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.goldenchart.entity.GcPaperOrder;
|
||||
import com.goldenchart.repository.GcPaperOrderRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class PaperOrderMatcherService {
|
||||
|
||||
private final GcPaperOrderRepository orderRepo;
|
||||
private final PaperTradingService paperTradingService;
|
||||
|
||||
public PaperOrderMatcherService(GcPaperOrderRepository orderRepo,
|
||||
@Lazy PaperTradingService paperTradingService) {
|
||||
this.orderRepo = orderRepo;
|
||||
this.paperTradingService = paperTradingService;
|
||||
}
|
||||
|
||||
public void onPriceTick(String symbol, double price) {
|
||||
if (symbol == null || price <= 0) return;
|
||||
List<GcPaperOrder> pending = orderRepo.findByStatusAndSymbol("PENDING", symbol);
|
||||
for (GcPaperOrder o : pending) {
|
||||
try {
|
||||
paperTradingService.tryFillPendingOrder(o.getId(), price);
|
||||
} catch (Exception e) {
|
||||
log.debug("[PaperOrderMatcher] fill skip order={}: {}", o.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 3000)
|
||||
public void pollPendingOrders() {
|
||||
Set<String> symbols = new HashSet<>();
|
||||
for (GcPaperOrder o : orderRepo.findByStatus("PENDING")) {
|
||||
symbols.add(o.getSymbol());
|
||||
}
|
||||
for (String symbol : symbols) {
|
||||
try {
|
||||
double price = paperTradingService.resolveMarkPrice(symbol);
|
||||
if (price > 0) onPriceTick(symbol, price);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.goldenchart.dto.PaperDailySnapshotDto;
|
||||
import com.goldenchart.entity.GcPaperAccount;
|
||||
import com.goldenchart.entity.GcPaperDailySnapshot;
|
||||
import com.goldenchart.entity.GcPaperPosition;
|
||||
import com.goldenchart.repository.GcPaperAccountRepository;
|
||||
import com.goldenchart.repository.GcPaperDailySnapshotRepository;
|
||||
import com.goldenchart.repository.GcPaperPositionRepository;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.ta4j.core.Bar;
|
||||
import org.ta4j.core.BarSeries;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Transactional
|
||||
public class PaperSnapshotService {
|
||||
|
||||
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
|
||||
|
||||
private final GcPaperDailySnapshotRepository snapshotRepo;
|
||||
private final GcPaperAccountRepository accountRepo;
|
||||
private final GcPaperPositionRepository positionRepo;
|
||||
private final Ta4jStorage ta4jStorage;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<PaperDailySnapshotDto> list(Long accountId, LocalDate from, LocalDate to) {
|
||||
LocalDate f = from != null ? from : LocalDate.now(KST).minusDays(90);
|
||||
LocalDate t = to != null ? to : LocalDate.now(KST);
|
||||
return snapshotRepo.findByAccountIdAndSnapshotDateBetweenOrderBySnapshotDateAsc(accountId, f, t)
|
||||
.stream().map(this::toDto).toList();
|
||||
}
|
||||
|
||||
public void captureEodForAllAccounts() {
|
||||
LocalDate today = LocalDate.now(KST);
|
||||
for (GcPaperAccount account : accountRepo.findAll()) {
|
||||
try {
|
||||
captureEod(account, today);
|
||||
} catch (Exception e) {
|
||||
log.warn("[PaperSnapshot] account {} failed: {}", account.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void captureEod(GcPaperAccount account, LocalDate date) {
|
||||
List<GcPaperPosition> positions = positionRepo.findByAccountIdOrderBySymbolAsc(account.getId());
|
||||
Map<String, Double> marks = new HashMap<>();
|
||||
double stockEval = 0;
|
||||
for (GcPaperPosition p : positions) {
|
||||
double qty = p.getQuantity().doubleValue();
|
||||
if (qty <= 0) continue;
|
||||
double mark = resolveMark(p.getSymbol(), p.getAvgPrice().doubleValue());
|
||||
marks.put(p.getSymbol(), mark);
|
||||
stockEval += mark * qty;
|
||||
}
|
||||
double cash = account.getCashBalance().doubleValue();
|
||||
double total = cash + stockEval;
|
||||
double initial = account.getInitialCapitalSnapshot() != null
|
||||
? account.getInitialCapitalSnapshot().doubleValue() : cash;
|
||||
double cumRet = initial > 0 ? (total - initial) / initial * 100 : 0;
|
||||
|
||||
GcPaperDailySnapshot prev = snapshotRepo
|
||||
.findByAccountIdAndSnapshotDate(account.getId(), date.minusDays(1))
|
||||
.orElse(null);
|
||||
double prevTotal = prev != null ? prev.getTotalAsset().doubleValue() : initial;
|
||||
double dailyPnl = total - prevTotal;
|
||||
double dailyRet = prevTotal > 0 ? dailyPnl / prevTotal * 100 : 0;
|
||||
|
||||
GcPaperDailySnapshot snap = snapshotRepo.findByAccountIdAndSnapshotDate(account.getId(), date)
|
||||
.orElse(GcPaperDailySnapshot.builder()
|
||||
.accountId(account.getId())
|
||||
.snapshotDate(date)
|
||||
.build());
|
||||
snap.setCashBalance(BigDecimal.valueOf(cash).setScale(2, RoundingMode.HALF_UP));
|
||||
snap.setStockEvalAmount(BigDecimal.valueOf(stockEval).setScale(2, RoundingMode.HALF_UP));
|
||||
snap.setTotalAsset(BigDecimal.valueOf(total).setScale(2, RoundingMode.HALF_UP));
|
||||
snap.setDailyPnl(BigDecimal.valueOf(dailyPnl).setScale(2, RoundingMode.HALF_UP));
|
||||
snap.setDailyReturnPct(BigDecimal.valueOf(dailyRet).setScale(4, RoundingMode.HALF_UP));
|
||||
snap.setCumulativeReturnPct(BigDecimal.valueOf(cumRet).setScale(4, RoundingMode.HALF_UP));
|
||||
snapshotRepo.save(snap);
|
||||
}
|
||||
|
||||
private double resolveMark(String symbol, double fallback) {
|
||||
try {
|
||||
if (!ta4jStorage.exists(symbol, "1m")) return fallback;
|
||||
BarSeries series = ta4jStorage.getOrCreate(symbol, "1m");
|
||||
if (!series.isEmpty()) {
|
||||
Bar last = series.getLastBar();
|
||||
return last.getClosePrice().doubleValue();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private PaperDailySnapshotDto toDto(GcPaperDailySnapshot s) {
|
||||
return PaperDailySnapshotDto.builder()
|
||||
.snapshotDate(s.getSnapshotDate().toString())
|
||||
.cashBalance(s.getCashBalance().doubleValue())
|
||||
.stockEvalAmount(s.getStockEvalAmount().doubleValue())
|
||||
.totalAsset(s.getTotalAsset().doubleValue())
|
||||
.dailyPnl(s.getDailyPnl().doubleValue())
|
||||
.dailyReturnPct(s.getDailyReturnPct().doubleValue())
|
||||
.cumulativeReturnPct(s.getCumulativeReturnPct().doubleValue())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,56 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.GcLiveStrategySettingsRepository;
|
||||
import com.goldenchart.repository.GcPaperAccountRepository;
|
||||
import com.goldenchart.repository.GcPaperPositionRepository;
|
||||
import com.goldenchart.repository.GcPaperTradeRepository;
|
||||
import com.goldenchart.entity.*;
|
||||
import com.goldenchart.repository.*;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.ta4j.core.Bar;
|
||||
import org.ta4j.core.BarSeries;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
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 static final int SCALE_QTY = 12;
|
||||
private static final int SCALE_KRW = 2;
|
||||
private static final RoundingMode RM = RoundingMode.HALF_UP;
|
||||
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
|
||||
|
||||
private final GcPaperAccountRepository accountRepo;
|
||||
private final GcPaperPositionRepository positionRepo;
|
||||
private final GcPaperTradeRepository tradeRepo;
|
||||
private final AppSettingsService appSettingsService;
|
||||
private final GcPaperAccountRepository accountRepo;
|
||||
private final GcPaperPositionRepository positionRepo;
|
||||
private final GcPaperTradeRepository tradeRepo;
|
||||
private final GcPaperOrderRepository orderRepo;
|
||||
private final GcPaperResetLogRepository resetLogRepo;
|
||||
private final AppSettingsService appSettingsService;
|
||||
private final GcLiveStrategySettingsRepository liveSettingsRepo;
|
||||
private final PaperAllocationService allocationService;
|
||||
private final PaperLedgerService ledgerService;
|
||||
private final PaperSnapshotService snapshotService;
|
||||
private final Ta4jStorage ta4jStorage;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
// ── 조회 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PaperSummaryDto getSummary(Long userId, String deviceId, Map<String, Double> markPrices) {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
@@ -74,15 +84,26 @@ public class PaperTradingService {
|
||||
}
|
||||
|
||||
double cash = account.getCashBalance().doubleValue();
|
||||
double initial = app.getPaperInitialCapital() != null
|
||||
? app.getPaperInitialCapital().doubleValue() : 10_000_000;
|
||||
double reserved = account.getReservedCash() != null
|
||||
? account.getReservedCash().doubleValue() : 0;
|
||||
double orderable = cash - reserved;
|
||||
double initialSnap = account.getInitialCapitalSnapshot() != null
|
||||
? account.getInitialCapitalSnapshot().doubleValue()
|
||||
: (app.getPaperInitialCapital() != null
|
||||
? app.getPaperInitialCapital().doubleValue() : 10_000_000);
|
||||
double total = cash + stockEval;
|
||||
double returnPct = initial > 0 ? (total - initial) / initial * 100 : 0;
|
||||
double returnPct = initialSnap > 0 ? (total - initialSnap) / initialSnap * 100 : 0;
|
||||
|
||||
List<PaperAllocationDto> allocations = allocationService.listWithMetrics(
|
||||
account.getId(), markPrices, total);
|
||||
|
||||
return PaperSummaryDto.builder()
|
||||
.enabled(Boolean.TRUE.equals(app.getPaperTradingEnabled()))
|
||||
.initialCapital(initial)
|
||||
.initialCapital(initialSnap)
|
||||
.cashBalance(cash)
|
||||
.reservedCash(reserved)
|
||||
.orderableCash(orderable)
|
||||
.initialCapitalSnapshot(initialSnap)
|
||||
.stockEvalAmount(stockEval)
|
||||
.totalAsset(total)
|
||||
.unrealizedPnl(unrealized)
|
||||
@@ -95,22 +116,90 @@ public class PaperTradingService {
|
||||
.autoTradeBudgetPct(app.getPaperAutoTradeBudgetPct() != null
|
||||
? app.getPaperAutoTradeBudgetPct().doubleValue() : 95)
|
||||
.positions(posDtos)
|
||||
.allocations(allocations)
|
||||
.build();
|
||||
}
|
||||
|
||||
public List<PaperTradeDto> listTrades(Long userId, String deviceId) {
|
||||
@Transactional(readOnly = true)
|
||||
public List<PaperAllocationDto> listAllocations(Long userId, String deviceId,
|
||||
Map<String, Double> markPrices) {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
return tradeRepo.findTop100ByAccountIdOrderByCreatedAtDesc(account.getId())
|
||||
.stream().map(this::toTradeDto).toList();
|
||||
PaperSummaryDto s = getSummary(userId, deviceId, markPrices);
|
||||
return allocationService.listWithMetrics(account.getId(), markPrices, s.getTotalAsset());
|
||||
}
|
||||
|
||||
public List<PaperAllocationDto> bulkAllocations(Long userId, String deviceId,
|
||||
PaperAllocationBulkRequest req) {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
BigDecimal defaultMax = account.getInitialCapitalSnapshot() != null
|
||||
? account.getInitialCapitalSnapshot()
|
||||
: app.getPaperInitialCapital();
|
||||
return allocationService.bulkUpsert(account.getId(), req, defaultMax);
|
||||
}
|
||||
|
||||
public PaperAllocationDto patchAllocation(Long userId, String deviceId, String symbol,
|
||||
PaperAllocationPatchRequest req) {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
return allocationService.patch(account.getId(), symbol, req);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PaperPageDto<PaperTradeDto> listTrades(Long userId, String deviceId,
|
||||
String symbol, String side, String source,
|
||||
LocalDateTime from, LocalDateTime to,
|
||||
int page, int size) {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
PageRequest pr = PageRequest.of(Math.max(0, page), Math.min(100, Math.max(1, size)));
|
||||
Page<GcPaperTrade> result = tradeRepo.findFiltered(
|
||||
account.getId(), symbol, side, source, from, to, pr);
|
||||
return PaperPageDto.<PaperTradeDto>builder()
|
||||
.content(result.getContent().stream().map(this::toTradeDto).toList())
|
||||
.page(result.getNumber())
|
||||
.size(result.getSize())
|
||||
.totalElements(result.getTotalElements())
|
||||
.totalPages(result.getTotalPages())
|
||||
.build();
|
||||
}
|
||||
|
||||
public List<PaperTradeDto> listTradesLegacy(Long userId, String deviceId) {
|
||||
return listTrades(userId, deviceId, null, null, null, null, null, 0, 100).getContent();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PaperPageDto<PaperLedgerEntryDto> listLedger(Long userId, String deviceId,
|
||||
LocalDateTime from, LocalDateTime to,
|
||||
int page, int size) {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
return ledgerService.list(account.getId(), from, to, page, size);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<PaperDailySnapshotDto> listDailySnapshots(Long userId, String deviceId,
|
||||
LocalDate from, LocalDate to) {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
return snapshotService.list(account.getId(), from, to);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<PaperOrderDto> listPendingOrders(Long userId, String deviceId) {
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId,
|
||||
appSettingsService.getEntity(userId, deviceId));
|
||||
return orderRepo.findByAccountIdAndStatusOrderByCreatedAtDesc(account.getId(), "PENDING")
|
||||
.stream().map(this::toOrderDto).toList();
|
||||
}
|
||||
|
||||
// ── 주문 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public PaperTradeDto placeOrder(Long userId, String deviceId, PaperOrderRequest req) {
|
||||
public PaperPlaceOrderResult placeOrder(Long userId, String deviceId, PaperOrderRequest req) {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) {
|
||||
throw new IllegalStateException("모의투자가 비활성화되어 있습니다. 설정에서 모의투자를 켜 주세요.");
|
||||
throw new IllegalStateException("모의투자가 비활성화되어 있습니다.");
|
||||
}
|
||||
if (req.getMarket() == null || req.getSide() == null) {
|
||||
throw new IllegalArgumentException("종목과 매수/매도 구분이 필요합니다.");
|
||||
@@ -124,11 +213,13 @@ public class PaperTradingService {
|
||||
throw new IllegalArgumentException("가격은 0보다 커야 합니다.");
|
||||
}
|
||||
|
||||
String orderType = resolveOrderType(req);
|
||||
String source = req.getSource() != null ? req.getSource() : "MANUAL";
|
||||
String orderKind = req.getOrderKind() != null ? req.getOrderKind() : "limit";
|
||||
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
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());
|
||||
}
|
||||
@@ -136,14 +227,34 @@ public class PaperTradingService {
|
||||
throw new IllegalArgumentException("주문 수량을 계산할 수 없습니다.");
|
||||
}
|
||||
|
||||
return executeTrade(userId, deviceId, app, req.getMarket(), side, orderKind, source,
|
||||
req.getStrategyId(), price, qty, null);
|
||||
if ("LIMIT".equals(orderType)) {
|
||||
GcPaperOrder order = createPendingLimitOrder(account, app, req.getMarket(), side, orderKind,
|
||||
source, req.getStrategyId(), price, qty);
|
||||
return PaperPlaceOrderResult.builder().order(toOrderDto(order)).build();
|
||||
}
|
||||
|
||||
PaperTradeDto trade = executeTrade(userId, deviceId, app, req.getMarket(), side, orderKind, source,
|
||||
req.getStrategyId(), price, qty, null, false);
|
||||
return PaperPlaceOrderResult.builder().trade(trade).build();
|
||||
}
|
||||
|
||||
public PaperOrderDto cancelOrder(Long userId, String deviceId, Long orderId) {
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId,
|
||||
appSettingsService.getEntity(userId, deviceId));
|
||||
GcPaperOrder order = orderRepo.findById(orderId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다."));
|
||||
if (!order.getAccountId().equals(account.getId())) {
|
||||
throw new IllegalArgumentException("권한이 없습니다.");
|
||||
}
|
||||
if (!"PENDING".equals(order.getStatus())) {
|
||||
throw new IllegalStateException("취소할 수 없는 주문 상태입니다.");
|
||||
}
|
||||
releaseReservation(account, order);
|
||||
order.setStatus("CANCELLED");
|
||||
orderRepo.save(order);
|
||||
return toOrderDto(order);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 전략 시그널 발생 시 자동 모의매매.
|
||||
* liveStrategyCheck + paperAutoTradeEnabled + paperTradingEnabled 일 때만 실행.
|
||||
*/
|
||||
public void tryExecuteOnSignal(String deviceId, Long userId, String market,
|
||||
Long strategyId, String signalType, double price) {
|
||||
if (deviceId == null || deviceId.isBlank()) return;
|
||||
@@ -166,91 +277,200 @@ public class PaperTradingService {
|
||||
if ("BUY".equals(signalType)) {
|
||||
double budgetPct = pct(app.getPaperAutoTradeBudgetPct(), 95);
|
||||
double cash = account.getCashBalance().doubleValue();
|
||||
double budget = cash * budgetPct / 100.0;
|
||||
double reserved = account.getReservedCash() != null
|
||||
? account.getReservedCash().doubleValue() : 0;
|
||||
double budget = (cash - reserved) * 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;
|
||||
}
|
||||
if (denom <= 0 || budget < minOrder) return;
|
||||
double qty = budget / denom;
|
||||
if (qty * execPrice < minOrder) return;
|
||||
executeTrade(userId, deviceId, app, market, "BUY", "market", "STRATEGY",
|
||||
strategyId, price, qty, null);
|
||||
strategyId, price, qty, null, true);
|
||||
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();
|
||||
if (pos == null || pos.getQuantity().doubleValue() <= 0) return;
|
||||
double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
|
||||
if (avail <= 0) return;
|
||||
executeTrade(userId, deviceId, app, market, "SELL", "market", "STRATEGY",
|
||||
strategyId, price, qty, null);
|
||||
log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, qty, price);
|
||||
strategyId, price, avail, null, true);
|
||||
log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, avail, price);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[Paper] auto trade failed {} {}: {}", market, signalType, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 계좌 초기화 — 설정의 초기 자본으로 리셋 */
|
||||
public void tryFillPendingOrder(Long orderId, double markPrice) {
|
||||
GcPaperOrder order = orderRepo.findById(orderId).orElse(null);
|
||||
if (order == null || !"PENDING".equals(order.getStatus())) return;
|
||||
|
||||
boolean fill = false;
|
||||
double limit = order.getLimitPrice() != null ? order.getLimitPrice().doubleValue() : 0;
|
||||
if ("BUY".equals(order.getSide())) {
|
||||
fill = markPrice <= limit;
|
||||
} else if ("SELL".equals(order.getSide())) {
|
||||
fill = markPrice >= limit;
|
||||
}
|
||||
if (!fill) return;
|
||||
|
||||
GcPaperAccount account = accountRepo.findById(order.getAccountId()).orElse(null);
|
||||
if (account == null) return;
|
||||
GcAppSettings app = appSettingsService.getEntity(account.getUserId(), account.getDeviceId());
|
||||
|
||||
releaseReservation(account, order);
|
||||
double qty = order.getQuantity().doubleValue();
|
||||
executeTrade(account.getUserId(), account.getDeviceId(), app, order.getSymbol(),
|
||||
order.getSide(), order.getOrderKind(), order.getSource(), order.getStrategyId(),
|
||||
limit, qty, null, "STRATEGY".equals(order.getSource()));
|
||||
|
||||
order.setStatus("FILLED");
|
||||
order.setFilledQuantity(order.getQuantity());
|
||||
order.setFilledAt(LocalDateTime.now());
|
||||
order.setReservedCash(BigDecimal.ZERO);
|
||||
order.setReservedQty(BigDecimal.ZERO);
|
||||
orderRepo.save(order);
|
||||
}
|
||||
|
||||
public double resolveMarkPrice(String symbol) {
|
||||
try {
|
||||
if (!ta4jStorage.exists(symbol, "1m")) return 0;
|
||||
BarSeries series = ta4jStorage.getOrCreate(symbol, "1m");
|
||||
if (!series.isEmpty()) {
|
||||
Bar last = series.getLastBar();
|
||||
return last.getClosePrice().doubleValue();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
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);
|
||||
BigDecimal cashBefore = account.getCashBalance();
|
||||
|
||||
String positionsJson = null;
|
||||
try {
|
||||
List<GcPaperPosition> positions = positionRepo.findByAccountIdOrderBySymbolAsc(account.getId());
|
||||
positionsJson = objectMapper.writeValueAsString(positions);
|
||||
} catch (Exception e) {
|
||||
log.debug("[Paper] reset positions json skip: {}", e.getMessage());
|
||||
}
|
||||
|
||||
resetLogRepo.save(GcPaperResetLog.builder()
|
||||
.accountId(account.getId())
|
||||
.initialCapital(initial)
|
||||
.cashBefore(cashBefore)
|
||||
.positionsJson(positionsJson)
|
||||
.reason("USER_RESET")
|
||||
.build());
|
||||
|
||||
ledgerService.recordReset(account, cashBefore, initial);
|
||||
|
||||
orderRepo.findByAccountIdAndStatusOrderByCreatedAtDesc(account.getId(), "PENDING")
|
||||
.forEach(o -> {
|
||||
releaseReservation(account, o);
|
||||
o.setStatus("CANCELLED");
|
||||
orderRepo.save(o);
|
||||
});
|
||||
|
||||
positionRepo.findByAccountIdOrderBySymbolAsc(account.getId()).forEach(positionRepo::delete);
|
||||
allocationService.deleteAllForAccount(account.getId());
|
||||
|
||||
positionRepo.findByAccountIdOrderBySymbolAsc(account.getId())
|
||||
.forEach(positionRepo::delete);
|
||||
account.setCashBalance(initial);
|
||||
account.setRealizedPnl(BigDecimal.ZERO);
|
||||
account.setReservedCash(BigDecimal.ZERO);
|
||||
account.setInitialCapitalSnapshot(initial);
|
||||
account.setResetCount((account.getResetCount() != null ? account.getResetCount() : 0) + 1);
|
||||
account.setLastResetAt(LocalDateTime.now());
|
||||
accountRepo.save(account);
|
||||
|
||||
allocationService.seedFromPositions(account.getId(), initial);
|
||||
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) {
|
||||
// ── 내부 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private GcPaperOrder createPendingLimitOrder(GcPaperAccount account, GcAppSettings app,
|
||||
String market, String side, String orderKind,
|
||||
String source, Long strategyId,
|
||||
double limitPrice, double qty) {
|
||||
double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
|
||||
double slip = pct(app.getPaperSlippagePct(), 0);
|
||||
double pctVal = budgetPct != null ? budgetPct : pct(app.getPaperAutoTradeBudgetPct(), 95);
|
||||
double execPrice = "BUY".equals(side)
|
||||
? limitPrice * (1 + slip / 100.0) : limitPrice * (1 - slip / 100.0);
|
||||
BigDecimal qtyBd = BigDecimal.valueOf(qty).setScale(SCALE_QTY, RM);
|
||||
BigDecimal priceBd = BigDecimal.valueOf(execPrice).setScale(SCALE_KRW, RM);
|
||||
BigDecimal gross = priceBd.multiply(qtyBd).setScale(SCALE_KRW, RM);
|
||||
BigDecimal fee = gross.multiply(BigDecimal.valueOf(feeRate / 100.0)).setScale(SCALE_KRW, RM);
|
||||
|
||||
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;
|
||||
BigDecimal need = gross.add(fee);
|
||||
allocationService.validateBuy(account, app, market, need, false);
|
||||
account.setReservedCash(account.getReservedCash().add(need));
|
||||
accountRepo.save(account);
|
||||
return orderRepo.save(GcPaperOrder.builder()
|
||||
.accountId(account.getId())
|
||||
.symbol(market)
|
||||
.side("BUY")
|
||||
.orderType("LIMIT")
|
||||
.limitPrice(BigDecimal.valueOf(limitPrice).setScale(SCALE_KRW, RM))
|
||||
.quantity(qtyBd)
|
||||
.status("PENDING")
|
||||
.reservedCash(need)
|
||||
.orderKind(orderKind)
|
||||
.source(source)
|
||||
.strategyId(strategyId)
|
||||
.build());
|
||||
}
|
||||
|
||||
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
|
||||
.orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다."));
|
||||
double held = pos.getQuantity().doubleValue();
|
||||
if (held <= 0) {
|
||||
throw new IllegalStateException("보유 수량이 없습니다.");
|
||||
double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
|
||||
if (qty > avail + 1e-12) {
|
||||
throw new IllegalStateException("매도 가능 수량이 부족합니다.");
|
||||
}
|
||||
return held * pctVal / 100.0;
|
||||
return orderRepo.save(GcPaperOrder.builder()
|
||||
.accountId(account.getId())
|
||||
.symbol(market)
|
||||
.side("SELL")
|
||||
.orderType("LIMIT")
|
||||
.limitPrice(BigDecimal.valueOf(limitPrice).setScale(SCALE_KRW, RM))
|
||||
.quantity(qtyBd)
|
||||
.status("PENDING")
|
||||
.reservedQty(qtyBd)
|
||||
.orderKind(orderKind)
|
||||
.source(source)
|
||||
.strategyId(strategyId)
|
||||
.build());
|
||||
}
|
||||
|
||||
// ── 내부 체결 ───────────────────────────────────────────────────────────
|
||||
private void releaseReservation(GcPaperAccount account, GcPaperOrder order) {
|
||||
if (order.getReservedCash() != null && order.getReservedCash().signum() > 0) {
|
||||
account.setReservedCash(account.getReservedCash().subtract(order.getReservedCash()).max(BigDecimal.ZERO));
|
||||
accountRepo.save(account);
|
||||
}
|
||||
}
|
||||
|
||||
private double availableSellQty(Long accountId, String symbol, double held) {
|
||||
double reserved = orderRepo.findByAccountIdAndStatusOrderByCreatedAtDesc(accountId, "PENDING")
|
||||
.stream()
|
||||
.filter(o -> symbol.equals(o.getSymbol()) && "SELL".equals(o.getSide()))
|
||||
.mapToDouble(o -> o.getReservedQty().doubleValue())
|
||||
.sum();
|
||||
return Math.max(0, held - reserved);
|
||||
}
|
||||
|
||||
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) {
|
||||
String koreanName, boolean autoTrade) {
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
|
||||
double slip = pct(app.getPaperSlippagePct(), 0);
|
||||
@@ -271,13 +491,14 @@ public class PaperTradingService {
|
||||
|
||||
if ("BUY".equals(side)) {
|
||||
BigDecimal totalCost = gross.add(fee);
|
||||
if (account.getCashBalance().compareTo(totalCost) < 0) {
|
||||
throw new IllegalStateException("주문가능 현금이 부족합니다.");
|
||||
}
|
||||
allocationService.validateBuy(account, app, market, totalCost, autoTrade);
|
||||
account.setCashBalance(account.getCashBalance().subtract(totalCost));
|
||||
upsertPositionBuy(account, market, koreanName, qty, price);
|
||||
accountRepo.save(account);
|
||||
|
||||
GcPaperPosition after = positionRepo.findByAccountIdAndSymbol(account.getId(), market).orElse(null);
|
||||
BigDecimal qtyAfter = after != null ? after.getQuantity() : qty;
|
||||
|
||||
GcPaperTrade trade = tradeRepo.save(GcPaperTrade.builder()
|
||||
.accountId(account.getId())
|
||||
.symbol(market)
|
||||
@@ -291,15 +512,17 @@ public class PaperTradingService {
|
||||
.feeAmount(fee)
|
||||
.netAmount(totalCost.negate())
|
||||
.cashAfter(account.getCashBalance())
|
||||
.positionQtyAfter(qtyAfter)
|
||||
.build());
|
||||
ledgerService.recordBuySettle(account, trade, totalCost);
|
||||
return toTradeDto(trade);
|
||||
}
|
||||
|
||||
// SELL
|
||||
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
|
||||
.orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다."));
|
||||
if (pos.getQuantity().compareTo(qty) < 0) {
|
||||
throw new IllegalStateException("매도 수량이 보유 수량을 초과합니다.");
|
||||
double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
|
||||
if (qty.doubleValue() > avail + 1e-12) {
|
||||
throw new IllegalStateException("매도 수량이 보유(가용) 수량을 초과합니다.");
|
||||
}
|
||||
|
||||
BigDecimal netProceeds = gross.subtract(fee);
|
||||
@@ -317,6 +540,9 @@ public class PaperTradingService {
|
||||
}
|
||||
accountRepo.save(account);
|
||||
|
||||
GcPaperPosition afterPos = positionRepo.findByAccountIdAndSymbol(account.getId(), market).orElse(null);
|
||||
BigDecimal qtyAfter = afterPos != null ? afterPos.getQuantity() : BigDecimal.ZERO;
|
||||
|
||||
GcPaperTrade trade = tradeRepo.save(GcPaperTrade.builder()
|
||||
.accountId(account.getId())
|
||||
.symbol(market)
|
||||
@@ -330,14 +556,52 @@ public class PaperTradingService {
|
||||
.feeAmount(fee)
|
||||
.netAmount(netProceeds)
|
||||
.cashAfter(account.getCashBalance())
|
||||
.realizedPnlDelta(realizedDelta)
|
||||
.positionQtyAfter(qtyAfter)
|
||||
.build());
|
||||
ledgerService.recordSellSettle(account, trade, netProceeds);
|
||||
return toTradeDto(trade);
|
||||
}
|
||||
|
||||
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 reserved = account.getReservedCash() != null
|
||||
? account.getReservedCash().doubleValue() : 0;
|
||||
double cash = account.getCashBalance().doubleValue() - reserved;
|
||||
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 avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
|
||||
if (avail <= 0) throw new IllegalStateException("보유 수량이 없습니다.");
|
||||
return avail * pctVal / 100.0;
|
||||
}
|
||||
|
||||
private static String resolveOrderType(PaperOrderRequest req) {
|
||||
if (req.getOrderType() != null) {
|
||||
String t = req.getOrderType().toUpperCase();
|
||||
if ("MARKET".equals(t) || "LIMIT".equals(t)) return t;
|
||||
}
|
||||
String kind = req.getOrderKind() != null ? req.getOrderKind().toLowerCase() : "limit";
|
||||
return "market".equals(kind) ? "MARKET" : "LIMIT";
|
||||
}
|
||||
|
||||
private void upsertPositionBuy(GcPaperAccount account, String market, String koreanName,
|
||||
BigDecimal qty, BigDecimal price) {
|
||||
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
|
||||
.orElse(null);
|
||||
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market).orElse(null);
|
||||
if (pos == null) {
|
||||
positionRepo.save(GcPaperPosition.builder()
|
||||
.accountId(account.getId())
|
||||
@@ -346,6 +610,9 @@ public class PaperTradingService {
|
||||
.quantity(qty)
|
||||
.avgPrice(price)
|
||||
.build());
|
||||
BigDecimal defaultMax = account.getInitialCapitalSnapshot() != null
|
||||
? account.getInitialCapitalSnapshot() : BigDecimal.valueOf(10_000_000);
|
||||
allocationService.getOrDefault(account.getId(), market, defaultMax);
|
||||
return;
|
||||
}
|
||||
BigDecimal totalQty = pos.getQuantity().add(qty);
|
||||
@@ -367,8 +634,14 @@ public class PaperTradingService {
|
||||
.deviceId(dev)
|
||||
.cashBalance(initial)
|
||||
.realizedPnl(BigDecimal.ZERO)
|
||||
.reservedCash(BigDecimal.ZERO)
|
||||
.initialCapitalSnapshot(initial)
|
||||
.resetCount(0)
|
||||
.status("ACTIVE")
|
||||
.build();
|
||||
return accountRepo.save(a);
|
||||
a = accountRepo.save(a);
|
||||
ledgerService.recordInitial(a, initial);
|
||||
return a;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -390,7 +663,31 @@ public class PaperTradingService {
|
||||
.feeAmount(t.getFeeAmount().doubleValue())
|
||||
.netAmount(t.getNetAmount().doubleValue())
|
||||
.cashAfter(t.getCashAfter().doubleValue())
|
||||
.realizedPnlDelta(t.getRealizedPnlDelta() != null
|
||||
? t.getRealizedPnlDelta().doubleValue() : null)
|
||||
.positionQtyAfter(t.getPositionQtyAfter() != null
|
||||
? t.getPositionQtyAfter().doubleValue() : null)
|
||||
.createdAt(t.getCreatedAt() != null ? t.getCreatedAt().toString() : null)
|
||||
.build();
|
||||
}
|
||||
|
||||
private PaperOrderDto toOrderDto(GcPaperOrder o) {
|
||||
return PaperOrderDto.builder()
|
||||
.id(o.getId())
|
||||
.symbol(o.getSymbol())
|
||||
.side(o.getSide())
|
||||
.orderType(o.getOrderType())
|
||||
.limitPrice(o.getLimitPrice() != null ? o.getLimitPrice().doubleValue() : null)
|
||||
.quantity(o.getQuantity().doubleValue())
|
||||
.filledQuantity(o.getFilledQuantity().doubleValue())
|
||||
.status(o.getStatus())
|
||||
.reservedCash(o.getReservedCash().doubleValue())
|
||||
.reservedQty(o.getReservedQty().doubleValue())
|
||||
.orderKind(o.getOrderKind())
|
||||
.source(o.getSource())
|
||||
.strategyId(o.getStrategyId())
|
||||
.createdAt(o.getCreatedAt() != null ? o.getCreatedAt().toString() : null)
|
||||
.updatedAt(o.getUpdatedAt() != null ? o.getUpdatedAt().toString() : null)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user