Skip to content

Commit

Permalink
feat: implement portfolio api (#93)
Browse files Browse the repository at this point in the history
* chore: remove unnecessary import

* feat: add portfolio command service

* feat: add portfolio query service

* feat: add portfolio controller

* docs: add portfolio controller docs

* fix: remove portfolio command service

* test: add test for create portfolio api

* feat: add monthly/yearly dividend api

* feat: add monthly/yearly dividend api

* docs: add swagger docs

* feat: implement dividend repository custom

* test: add portfolio query service test

* test: add portfolio controller test

* feat: add sector-ratio service

* feat: update portfolio controller

* test: add test code

* test: add service test code

* feat: update swagger docs

---------

Co-authored-by: Songyi Kim <[email protected]>
  • Loading branch information
chominho96 and songyi00 authored Apr 21, 2024
1 parent ab10b87 commit 88f0363
Show file tree
Hide file tree
Showing 25 changed files with 1,231 additions and 6 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package nexters.payout.apiserver.portfolio.application;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import nexters.payout.apiserver.portfolio.application.dto.request.PortfolioRequest;
import nexters.payout.apiserver.portfolio.application.dto.response.*;
import nexters.payout.apiserver.stock.application.dto.response.SectorRatioResponse;
import nexters.payout.core.time.InstantProvider;
import nexters.payout.domain.dividend.domain.Dividend;
import nexters.payout.domain.dividend.domain.repository.DividendRepository;
import nexters.payout.domain.portfolio.domain.Portfolio;
import nexters.payout.domain.portfolio.domain.PortfolioStock;
import nexters.payout.domain.portfolio.domain.exception.PortfolioNotFoundException;
import nexters.payout.domain.portfolio.domain.repository.PortfolioRepository;
import nexters.payout.domain.stock.domain.Sector;
import nexters.payout.domain.stock.domain.Stock;
import nexters.payout.domain.stock.domain.exception.StockIdNotFoundException;
import nexters.payout.domain.stock.domain.exception.TickerNotFoundException;
import nexters.payout.domain.stock.domain.repository.StockRepository;
import nexters.payout.domain.stock.domain.service.SectorAnalysisService;
import nexters.payout.domain.stock.domain.service.SectorAnalysisService.SectorInfo;
import nexters.payout.domain.stock.domain.service.SectorAnalysisService.StockShare;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class PortfolioQueryService {

private final StockRepository stockRepository;
private final PortfolioRepository portfolioRepository;
private final DividendRepository dividendRepository;
private final SectorAnalysisService sectorAnalysisService;

public PortfolioResponse createPortfolio(final PortfolioRequest request) {

List<PortfolioStock> portfolioStocks =
request.tickerShares()
.stream()
.map(tickerShare -> new PortfolioStock(
getStockByTicker(tickerShare.ticker()).getId(),
tickerShare.share())
)
.toList();

return new PortfolioResponse(
portfolioRepository.save(new Portfolio(InstantProvider.getExpireAt(), portfolioStocks))
.getId()
);
}

public List<SectorRatioResponse> analyzeSectorRatio(final UUID portfolioId) {
List<PortfolioStock> portfolioStocks = getPortfolio(portfolioId).portfolioStocks();
List<StockShare> stockShares = portfolioStocks
.stream()
.map(ps -> new StockShare(getStock(ps.getStockId()), ps.getShares()))
.toList();
Map<Sector, SectorInfo> sectorInfoMap = sectorAnalysisService.calculateSectorRatios(stockShares);

return SectorRatioResponse.fromMap(sectorInfoMap);
}

@Transactional(readOnly = true)
public List<MonthlyDividendResponse> getMonthlyDividends(final UUID id) {
return InstantProvider.generateNext12Months()
.stream()
.map(yearMonth -> MonthlyDividendResponse.of(
yearMonth.getYear(),
yearMonth.getMonthValue(),
getDividendsOfLastYearAndMonth(
getPortfolio(id).portfolioStocks(),
yearMonth.getMonthValue()
)
)
)
.collect(Collectors.toList());
}

private Stock getStockByTicker(String ticker) {
return stockRepository.findByTicker(ticker)
.orElseThrow(() -> new TickerNotFoundException(ticker));
}

private Stock getStock(UUID stockId) {
return stockRepository.findById(stockId).orElseThrow(() -> new StockIdNotFoundException(stockId));
}

private Portfolio getPortfolio(UUID id) {
return portfolioRepository.findById(id)
.orElseThrow(() -> new PortfolioNotFoundException(id));
}

@Transactional(readOnly = true)
public YearlyDividendResponse getYearlyDividends(final UUID id) {

List<SingleYearlyDividendResponse> dividends = getPortfolio(id)
.portfolioStocks()
.stream()
.map(portfolioStock -> {
Stock stock = getStock(portfolioStock.getStockId());
return SingleYearlyDividendResponse.of(
stock, portfolioStock.getShares(), getYearlyDividend(stock.getId())
);
})
.filter(response -> response.totalDividend() != 0)
.toList();

return YearlyDividendResponse.of(dividends);
}

private double getYearlyDividend(final UUID stockId) {
return getLastYearDividendsByStockId(stockId)
.stream()
.mapToDouble(Dividend::getDividend)
.sum();
}

private List<Dividend> getLastYearDividendsByStockId(final UUID id) {
return dividendRepository.findAllByIdAndYear(id, InstantProvider.getLastYear());
}

private List<SingleMonthlyDividendResponse> getDividendsOfLastYearAndMonth(
final List<PortfolioStock> portfolioStocks, final int month
) {
return portfolioStocks
.stream()
.flatMap(portfolioStock -> stockRepository.findById(portfolioStock.getStockId())
.map(stock -> getMonthlyDividendResponse(month, portfolioStock, stock))
.orElseThrow(() -> new StockIdNotFoundException(portfolioStock.getStockId())))
.toList();
}

private Stream<SingleMonthlyDividendResponse> getMonthlyDividendResponse(
final int month, final PortfolioStock portfolioStock, final Stock stock
) {
return getLastYearDividendsByStockIdAndMonth(portfolioStock.getStockId(), month)
.stream()
.map(dividend -> SingleMonthlyDividendResponse.of(stock, portfolioStock.getShares(), dividend));
}

private List<Dividend> getLastYearDividendsByStockIdAndMonth(final UUID stockId, final int month) {
return dividendRepository.findAllByIdAndYearAndMonth(stockId, InstantProvider.getLastYear(), month);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package nexters.payout.apiserver.portfolio.application.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Size;

import java.util.List;

public record PortfolioRequest(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Valid
@Size(min = 1)
List<TickerShare> tickerShares
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package nexters.payout.apiserver.portfolio.application.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;

public record TickerShare(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "ticker name")
@NotEmpty
String ticker,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "share")
@Min(value = 1)
Integer share
) { }
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package nexters.payout.apiserver.portfolio.application.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

import java.util.Comparator;
import java.util.List;

public record MonthlyDividendResponse(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Integer year,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Integer month,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<SingleMonthlyDividendResponse> dividends,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Double totalDividend
) {
public static MonthlyDividendResponse of(
final int year, final int month, final List<SingleMonthlyDividendResponse> dividends
) {
return new MonthlyDividendResponse(
year,
month,
dividends.stream()
.sorted(Comparator.comparingDouble(SingleMonthlyDividendResponse::totalDividend).reversed())
.toList(),
dividends.stream()
.mapToDouble(SingleMonthlyDividendResponse::totalDividend)
.sum()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package nexters.payout.apiserver.portfolio.application.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

import java.util.UUID;

public record PortfolioResponse(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
UUID id
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package nexters.payout.apiserver.portfolio.application.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import nexters.payout.apiserver.stock.application.dto.response.StockShareResponse;
import nexters.payout.domain.stock.domain.Sector;
import nexters.payout.domain.stock.domain.service.SectorAnalysisService.SectorInfo;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public record SectorRatioResponse(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "sector name")
String sectorName,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "sector value")
String sectorValue,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "sector ratio")
Double sectorRatio,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<StockShareResponse> stockShares
) {
public static List<SectorRatioResponse> fromMap(final Map<Sector, SectorInfo> sectorRatioMap) {
return sectorRatioMap.entrySet()
.stream()
.map(entry -> new SectorRatioResponse(
entry.getKey().getName(),
entry.getKey().name(),
entry.getValue().ratio(),
entry.getValue()
.stockShares()
.stream()
.map(StockShareResponse::from)
.collect(Collectors.toList()))
)
.collect(Collectors.toList());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package nexters.payout.apiserver.portfolio.application.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import nexters.payout.domain.dividend.domain.Dividend;
import nexters.payout.domain.stock.domain.Stock;

public record SingleMonthlyDividendResponse(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
String ticker,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
String logoUrl,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Integer share,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Double dividend,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Double totalDividend
) {
public static SingleMonthlyDividendResponse of(Stock stock, int share, Dividend dividend) {
return new SingleMonthlyDividendResponse(
stock.getTicker(),
stock.getLogoUrl(),
share,
dividend.getDividend(),
dividend.getDividend() * share
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package nexters.payout.apiserver.portfolio.application.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import nexters.payout.domain.stock.domain.Stock;

public record SingleYearlyDividendResponse(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
String ticker,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
String logoUrl,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Integer share,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Double totalDividend
) {
public static SingleYearlyDividendResponse of(Stock stock, int share, double dividend) {
return new SingleYearlyDividendResponse(
stock.getTicker(),
stock.getLogoUrl(),
share,
dividend * share
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package nexters.payout.apiserver.portfolio.application.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

import java.util.Comparator;
import java.util.List;

public record YearlyDividendResponse(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<SingleYearlyDividendResponse> dividends,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Double totalDividend
) {
public static YearlyDividendResponse of(List<SingleYearlyDividendResponse> dividends) {

dividends = dividends
.stream()
.sorted(Comparator.comparingDouble(SingleYearlyDividendResponse::totalDividend).reversed())
.toList();
return new YearlyDividendResponse(
dividends,
dividends
.stream()
.mapToDouble(SingleYearlyDividendResponse::totalDividend)
.sum()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package nexters.payout.apiserver.portfolio.presentation;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import nexters.payout.apiserver.portfolio.application.PortfolioQueryService;
import nexters.payout.apiserver.portfolio.application.dto.request.PortfolioRequest;
import nexters.payout.apiserver.portfolio.application.dto.response.MonthlyDividendResponse;
import nexters.payout.apiserver.portfolio.application.dto.response.PortfolioResponse;
import nexters.payout.apiserver.portfolio.application.dto.response.YearlyDividendResponse;
import nexters.payout.apiserver.stock.application.dto.request.SectorRatioRequest;
import nexters.payout.apiserver.stock.application.dto.response.SectorRatioResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.UUID;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/portfolios")
@Slf4j
public class PortfolioController implements PortfolioControllerDocs {

private final PortfolioQueryService portfolioQueryService;

@PostMapping
public ResponseEntity<PortfolioResponse> createPortfolio(@RequestBody @Valid final PortfolioRequest portfolioRequest) {
return ResponseEntity.ok(portfolioQueryService.createPortfolio(portfolioRequest));
}

@GetMapping("/{id}/monthly")
public ResponseEntity<List<MonthlyDividendResponse>> getMonthlyDividends(@PathVariable("id") final UUID portfolioId) {
return ResponseEntity.ok(portfolioQueryService.getMonthlyDividends(portfolioId));
}

@GetMapping("/{id}/yearly")
public ResponseEntity<YearlyDividendResponse> getYearlyDividends(@PathVariable("id") final UUID portfolioId) {
return ResponseEntity.ok(portfolioQueryService.getYearlyDividends(portfolioId));
}

@GetMapping("/{id}/sector-ratio")
public ResponseEntity<List<SectorRatioResponse>> getSectorRatios(@PathVariable("id") final UUID portfolioId) {
return ResponseEntity.ok(portfolioQueryService.analyzeSectorRatio(portfolioId));
}
}
Loading

0 comments on commit 88f0363

Please sign in to comment.