Files
sb-backend/src/main/java/com/sb/web/account/service/InvestService.java
T
ByungCheolandClaude Opus 4.8 eded44a476 feat: 투자 매매내역 수정 API (PUT /trades/{id})
- InvestController/Service/Mapper: updateTrade 추가
- 수정 결과를 시점별로 시뮬레이션해 보유수량이 음수가 되면 거부(매도 과다 방지)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:42:33 +09:00

324 lines
14 KiB
Java

package com.sb.web.account.service;
import com.sb.web.account.domain.InvestHolding;
import com.sb.web.account.domain.InvestTrade;
import com.sb.web.account.domain.Wallet;
import com.sb.web.account.dto.HoldingRequest;
import com.sb.web.account.dto.HoldingResponse;
import com.sb.web.account.dto.TradeRequest;
import com.sb.web.account.dto.TradeResponse;
import com.sb.web.account.mapper.InvestMapper;
import com.sb.web.account.mapper.WalletMapper;
import com.sb.web.common.exception.ApiException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 투자 포트폴리오(C). 보유종목·매매이력 관리와 평단/실현·평가손익 산출.
* 모든 데이터는 memberId(소유자)로 격리한다.
*/
@Service
@RequiredArgsConstructor
public class InvestService {
private final InvestMapper investMapper;
private final WalletMapper walletMapper;
private final StockQuoteService stockQuoteService;
/* ===================== 보유 종목 ===================== */
public List<HoldingResponse> listHoldings(Long memberId, Long walletId) {
requireInvestWallet(walletId, memberId);
Map<Long, List<InvestTrade>> tradesByHolding = tradesByHolding(memberId);
return investMapper.findHoldingsByWallet(memberId, walletId).stream()
.map(h -> toResponse(h, tradesByHolding.getOrDefault(h.getId(), List.of())))
.toList();
}
@Transactional
public HoldingResponse createHolding(HoldingRequest req, Long memberId) {
requireInvestWallet(req.getWalletId(), memberId);
InvestHolding h = InvestHolding.builder()
.memberId(memberId)
.walletId(req.getWalletId())
.name(req.getName().trim())
.ticker(blankToNull(req.getTicker()))
.currentPrice(req.getCurrentPrice())
.build();
investMapper.insertHolding(h);
return toResponse(h, List.of());
}
@Transactional
public HoldingResponse updateHolding(Long id, HoldingRequest req, Long memberId) {
InvestHolding h = mustHolding(id, memberId);
h.setName(req.getName().trim());
h.setTicker(blankToNull(req.getTicker()));
h.setCurrentPrice(req.getCurrentPrice());
investMapper.updateHolding(h);
return toResponse(h, investMapper.findTradesByHolding(id, memberId));
}
@Transactional
public void deleteHolding(Long id, Long memberId) {
mustHolding(id, memberId);
investMapper.deleteTradesByHolding(id, memberId);
investMapper.deleteHolding(id, memberId);
}
/** 종목코드가 있는 보유종목의 현재가를 시세 조회로 갱신. 조회 성공한 종목만 반영 */
@Transactional
public List<HoldingResponse> refreshPrices(Long memberId, Long walletId) {
requireInvestWallet(walletId, memberId);
Map<String, Long> cache = new HashMap<>();
for (InvestHolding h : investMapper.findHoldingsByWallet(memberId, walletId)) {
applyQuote(h, cache);
}
return listHoldings(memberId, walletId);
}
/** 회원의 모든 투자계좌 보유종목 현재가를 일괄 갱신(계좌 목록 평가액 반영용) */
@Transactional
public void refreshAllPrices(Long memberId) {
Map<String, Long> cache = new HashMap<>();
for (InvestHolding h : investMapper.findHoldingsByMember(memberId)) {
applyQuote(h, cache);
}
}
/** 종목코드로 시세를 받아 현재가 갱신(동일 종목코드는 호출당 1회만 조회) */
private void applyQuote(InvestHolding h, Map<String, Long> cache) {
String ticker = h.getTicker();
if (ticker == null || ticker.isBlank()) return;
Long price = cache.computeIfAbsent(ticker.trim(), stockQuoteService::getDomesticPrice);
if (price != null && price > 0) {
h.setCurrentPrice(price);
investMapper.updateHolding(h);
}
}
/* ===================== 매매 ===================== */
public List<TradeResponse> listTrades(Long holdingId, Long memberId) {
mustHolding(holdingId, memberId);
return investMapper.findTradesByHolding(holdingId, memberId).stream()
.map(TradeResponse::from)
.toList();
}
@Transactional
public TradeResponse addTrade(Long holdingId, TradeRequest req, Long memberId) {
mustHolding(holdingId, memberId);
String type = "SELL".equals(req.getTradeType()) ? "SELL" : "BUY";
if ("SELL".equals(type)) {
Calc c = calc(investMapper.findTradesByHolding(holdingId, memberId));
if (req.getQuantity().compareTo(c.quantity()) > 0) {
throw new ApiException(HttpStatus.BAD_REQUEST,
"매도 수량이 보유수량(" + c.quantity().stripTrailingZeros().toPlainString() + ")을 초과합니다.");
}
}
InvestTrade t = InvestTrade.builder()
.memberId(memberId)
.holdingId(holdingId)
.tradeType(type)
.tradeDate(req.getTradeDate())
.quantity(req.getQuantity())
.price(req.getPrice())
.fee(req.getFee() != null ? req.getFee() : 0L)
.build();
investMapper.insertTrade(t);
return TradeResponse.from(t);
}
@Transactional
public TradeResponse updateTrade(Long id, TradeRequest req, Long memberId) {
InvestTrade existing = investMapper.findTradeByIdAndMember(id, memberId);
if (existing == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "매매 내역을 찾을 수 없습니다.");
}
Long holdingId = existing.getHoldingId();
String type = "SELL".equals(req.getTradeType()) ? "SELL" : "BUY";
long fee = req.getFee() != null ? req.getFee() : 0L;
// 편집을 반영한 시점별 시뮬레이션 — 어느 시점에도 보유수량이 음수가 되면 거부
java.util.List<InvestTrade> sim = new java.util.ArrayList<>();
for (InvestTrade t : investMapper.findTradesByHolding(holdingId, memberId)) {
if (t.getId().equals(id)) {
sim.add(InvestTrade.builder().id(id).tradeType(type).tradeDate(req.getTradeDate())
.quantity(req.getQuantity()).price(req.getPrice()).fee(fee).build());
} else {
sim.add(t);
}
}
sim.sort(java.util.Comparator.comparing(InvestTrade::getTradeDate)
.thenComparing(InvestTrade::getId));
BigDecimal running = BigDecimal.ZERO;
for (InvestTrade t : sim) {
if ("BUY".equals(t.getTradeType())) {
running = running.add(t.getQuantity());
} else {
running = running.subtract(t.getQuantity());
if (running.signum() < 0) {
throw new ApiException(HttpStatus.BAD_REQUEST,
"수정하면 보유수량이 음수가 됩니다. 매도 수량/순서를 확인하세요.");
}
}
}
existing.setTradeType(type);
existing.setTradeDate(req.getTradeDate());
existing.setQuantity(req.getQuantity());
existing.setPrice(req.getPrice());
existing.setFee(fee);
investMapper.updateTrade(existing);
return TradeResponse.from(existing);
}
@Transactional
public void deleteTrade(Long id, Long memberId) {
if (investMapper.findTradeByIdAndMember(id, memberId) == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "매매 내역을 찾을 수 없습니다.");
}
investMapper.deleteTrade(id, memberId);
}
/* ===================== 지갑 단위 평가 (AccountService 연동) ===================== */
/** 투자(INVEST) 지갑별 {예수금 증감(cashDelta), 주식 평가금액(stockEval)} */
public Map<Long, WalletInvest> valuationByWallet(Long memberId) {
Map<Long, List<InvestTrade>> tradesByHolding = tradesByHolding(memberId);
Map<Long, WalletInvest> result = new HashMap<>();
for (InvestHolding h : investMapper.findHoldingsByMember(memberId)) {
List<InvestTrade> trades = tradesByHolding.getOrDefault(h.getId(), List.of());
Calc c = calc(trades);
long evalPrice = h.getCurrentPrice() != null ? h.getCurrentPrice() : c.avgPrice();
WalletInvest wi = result.computeIfAbsent(h.getWalletId(), k -> new WalletInvest());
wi.cashDelta += cashDelta(trades);
wi.stockEval += amountOf(c.quantity(), evalPrice);
}
return result;
}
/** 투자 지갑별 가치 holder */
public static class WalletInvest {
public long cashDelta; // 매매로 인한 예수금 증감 (Σ매도대금 − Σ매수금액)
public long stockEval; // 주식 평가금액 (Σ수량×평가단가)
}
/* ===================== 내부 ===================== */
private Map<Long, List<InvestTrade>> tradesByHolding(Long memberId) {
Map<Long, List<InvestTrade>> map = new HashMap<>();
for (InvestTrade t : investMapper.findTradesByMember(memberId)) {
map.computeIfAbsent(t.getHoldingId(), k -> new java.util.ArrayList<>()).add(t);
}
return map;
}
/** 매매이력(시간순)으로 보유수량·원가·실현손익 산출. 수량은 소수점, 금액은 원 단위 정수. */
private Calc calc(List<InvestTrade> trades) {
BigDecimal qty = BigDecimal.ZERO;
long costBasis = 0, realized = 0;
for (InvestTrade t : trades) {
long amount = amountOf(t.getQuantity(), t.getPrice());
long fee = t.getFee() != null ? t.getFee() : 0L;
if ("BUY".equals(t.getTradeType())) {
qty = qty.add(t.getQuantity());
costBasis += amount + fee;
} else { // SELL
// 부분 매도 시 보유원가를 수량 비율로 제거, 전량 매도 시 잔여 원가 전부 제거(반올림 잔차 방지)
boolean partial = qty.signum() > 0 && t.getQuantity().compareTo(qty) < 0;
long costRemoved = partial
? Math.round(costBasis * t.getQuantity().doubleValue() / qty.doubleValue())
: costBasis;
long proceeds = amount - fee;
realized += proceeds - costRemoved;
costBasis -= costRemoved;
qty = qty.subtract(t.getQuantity());
if (qty.signum() <= 0) {
qty = BigDecimal.ZERO;
costBasis = 0;
}
}
}
return new Calc(qty, costBasis, realized);
}
private record Calc(BigDecimal quantity, long costBasis, long realized) {
long avgPrice() {
return quantity.signum() > 0 ? Math.round(costBasis / quantity.doubleValue()) : 0;
}
}
/** 거래금액(원) = 수량 × 단가, 원 단위 반올림 */
private static long amountOf(BigDecimal quantity, long price) {
return quantity.multiply(BigDecimal.valueOf(price))
.setScale(0, RoundingMode.HALF_UP)
.longValue();
}
/** 매매로 인한 예수금 증감 = −Σ매수(금액+수수료) + Σ매도(금액−수수료) */
private long cashDelta(List<InvestTrade> trades) {
long cashDelta = 0;
for (InvestTrade t : trades) {
long amount = amountOf(t.getQuantity(), t.getPrice());
long fee = t.getFee() != null ? t.getFee() : 0L;
cashDelta += "BUY".equals(t.getTradeType()) ? -(amount + fee) : (amount - fee);
}
return cashDelta;
}
private HoldingResponse toResponse(InvestHolding h, List<InvestTrade> trades) {
Calc c = calc(trades);
long avgPrice = c.avgPrice();
long evalPrice = h.getCurrentPrice() != null ? h.getCurrentPrice() : avgPrice;
long evalValue = amountOf(c.quantity(), evalPrice);
long evalGain = evalValue - c.costBasis();
Double returnPct = c.costBasis() > 0 ? Math.round(evalGain * 1000.0 / c.costBasis()) / 10.0 : null;
return HoldingResponse.builder()
.id(h.getId())
.walletId(h.getWalletId())
.name(h.getName())
.ticker(h.getTicker())
.currentPrice(h.getCurrentPrice())
.quantity(c.quantity())
.avgPrice(avgPrice)
.costBasis(c.costBasis())
.evalValue(evalValue)
.evalGain(evalGain)
.returnPct(returnPct)
.realizedPL(c.realized)
.build();
}
private void requireInvestWallet(Long walletId, Long memberId) {
Wallet w = walletMapper.findByIdAndMember(walletId, memberId);
if (w == null) {
throw new ApiException(HttpStatus.BAD_REQUEST, "투자계좌가 올바르지 않습니다.");
}
if (!"INVEST".equals(w.getType())) {
throw new ApiException(HttpStatus.BAD_REQUEST, "투자(INVEST) 계좌만 종목을 보유할 수 있습니다.");
}
}
private InvestHolding mustHolding(Long id, Long memberId) {
InvestHolding h = investMapper.findHoldingByIdAndMember(id, memberId);
if (h == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "종목을 찾을 수 없습니다.");
}
return h;
}
private String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
}