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 listHoldings(Long memberId, Long walletId) { requireInvestWallet(walletId, memberId); Map> 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 refreshPrices(Long memberId, Long walletId) { requireInvestWallet(walletId, memberId); Map cache = new HashMap<>(); for (InvestHolding h : investMapper.findHoldingsByWallet(memberId, walletId)) { applyQuote(h, cache); } return listHoldings(memberId, walletId); } /** 회원의 모든 투자계좌 보유종목 현재가를 일괄 갱신(계좌 목록 평가액 반영용) */ @Transactional public void refreshAllPrices(Long memberId) { Map cache = new HashMap<>(); for (InvestHolding h : investMapper.findHoldingsByMember(memberId)) { applyQuote(h, cache); } } /** 종목코드로 시세를 받아 현재가 갱신(동일 종목코드는 호출당 1회만 조회) */ private void applyQuote(InvestHolding h, Map 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 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 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 valuationByWallet(Long memberId) { Map> tradesByHolding = tradesByHolding(memberId); Map result = new HashMap<>(); for (InvestHolding h : investMapper.findHoldingsByMember(memberId)) { List 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> tradesByHolding(Long memberId) { Map> 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 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 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 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(); } }