- InvestController/Service/Mapper: updateTrade 추가 - 수정 결과를 시점별로 시뮬레이션해 보유수량이 음수가 되면 거부(매도 과다 방지) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
324 lines
14 KiB
Java
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();
|
|
}
|
|
}
|