From 5821bdb20259b82043ded822e9d6660113045b25 Mon Sep 17 00:00:00 2001 From: jerome Date: Sun, 27 Jul 2025 17:23:56 +0900 Subject: [PATCH 01/11] =?UTF-8?q?FEAT(#129):=20=EB=AA=A8=EC=9D=98=20?= =?UTF-8?q?=ED=88=AC=EC=9E=90=20=ED=98=84=ED=99=A9,=20=EC=A2=85=EB=AA=A9?= =?UTF-8?q?=20=EB=A7=A4=EC=88=98=20API=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ExperimentController.java | 36 ++++ .../dto/ExperimentSimpleResponse.java | 13 ++ .../dto/ExperimentStatusResponse.java | 20 ++ .../dto/ProgressExperimentItemResponse.java | 28 +++ .../experiment/entity/ExperimentItem.java | 57 ++++++ .../repository/ExperimentRepository.java | 22 +++ .../experiment/service/ExperimentService.java | 185 ++++++++++++++++++ .../stock/dto/response/StockInfoResponse.java | 4 + .../stock/service/SecurityService.java | 5 +- 9 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java create mode 100644 src/main/java/com/fund/stockProject/experiment/dto/ExperimentSimpleResponse.java create mode 100644 src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusResponse.java create mode 100644 src/main/java/com/fund/stockProject/experiment/dto/ProgressExperimentItemResponse.java create mode 100644 src/main/java/com/fund/stockProject/experiment/entity/ExperimentItem.java create mode 100644 src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java create mode 100644 src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java diff --git a/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java b/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java new file mode 100644 index 0000000..d810e07 --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java @@ -0,0 +1,36 @@ +package com.fund.stockProject.experiment.controller; + +import com.fund.stockProject.experiment.dto.ExperimentSimpleResponse; +import com.fund.stockProject.experiment.dto.ExperimentStatusResponse; +import com.fund.stockProject.experiment.service.ExperimentService; +import com.fund.stockProject.stock.domain.COUNTRY; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/experiment") +public class ExperimentController { + + private ExperimentService experimentService; + + @GetMapping("/status") + @Operation(summary = "실험(모의 매수) 현황 API", description = "실험(모의 매수) 현황 조회") + public ResponseEntity> getExperimentStatus(final @PathVariable Integer userId) { + return ResponseEntity.ok().body(experimentService.getExperimentStatus(userId)); + } + + @PostMapping("/{id}/buy/{country}") + @Operation(summary = "실험(모의 매수) 종목 매수 API", description = "실험(모의 매수) 종목 매수") + public ResponseEntity> buyExperimentItem(final @PathVariable Integer userId, final @PathVariable("id") Integer stockId, final @PathVariable("country") String country) { + return ResponseEntity.ok().body(experimentService.buyExperimentItem(userId, stockId, country)); + } +} diff --git a/src/main/java/com/fund/stockProject/experiment/dto/ExperimentSimpleResponse.java b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentSimpleResponse.java new file mode 100644 index 0000000..304c057 --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentSimpleResponse.java @@ -0,0 +1,13 @@ +package com.fund.stockProject.experiment.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ExperimentSimpleResponse { + private boolean success; + private String message; + private Double price; +} diff --git a/src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusResponse.java b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusResponse.java new file mode 100644 index 0000000..2193653 --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusResponse.java @@ -0,0 +1,20 @@ +package com.fund.stockProject.experiment.dto; + +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ExperimentStatusResponse { + + private List progressExperimentItems; + + private double avgRoi; // 평균수익률 + + private int totalPaperTradeCount; // 총 실험 수 + + private int progressPaperTradeCount; // 진행중인 실험 수 + + private double successRate; // 성공률 +} diff --git a/src/main/java/com/fund/stockProject/experiment/dto/ProgressExperimentItemResponse.java b/src/main/java/com/fund/stockProject/experiment/dto/ProgressExperimentItemResponse.java new file mode 100644 index 0000000..e925fad --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/dto/ProgressExperimentItemResponse.java @@ -0,0 +1,28 @@ +package com.fund.stockProject.experiment.dto; + +import java.time.LocalDateTime; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ProgressExperimentItemResponse { + + private Integer id; + + private String symbolName; + + private LocalDateTime buyAt; + + private Integer buyPrice; + + private Double currentPrice; + + private Integer autoSellIn; + + private Double diffPrice; + + private Double roi; + + private String tradeStatus; +} diff --git a/src/main/java/com/fund/stockProject/experiment/entity/ExperimentItem.java b/src/main/java/com/fund/stockProject/experiment/entity/ExperimentItem.java new file mode 100644 index 0000000..e545aea --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/entity/ExperimentItem.java @@ -0,0 +1,57 @@ +package com.fund.stockProject.experiment.entity; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fund.stockProject.auth.entity.User; +import com.fund.stockProject.stock.entity.Stock; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ExperimentItem { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "stock_id", referencedColumnName = "id", insertable = false, updatable = false) + private Stock stock; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", referencedColumnName = "id", nullable = false) + private User user; + + @JsonIgnore + private LocalDateTime buyAt; + + @JsonIgnore + private LocalDateTime sellAt; + + @Column(nullable = false) + private Double buyPrice; + + @Column(nullable = false) + private Integer sellPrice; + + @Column(nullable = false) + private Double roi; + + @Column(nullable = false) + private String tradeStatus; +} diff --git a/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java b/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java new file mode 100644 index 0000000..c75793c --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java @@ -0,0 +1,22 @@ +package com.fund.stockProject.experiment.repository; + +import com.fund.stockProject.experiment.entity.ExperimentItem; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface ExperimentRepository extends JpaRepository { + @Query("SELECT e FROM ExperimentItem e WHERE e.userId = :userId") + List findExperimentItemsByUserId(@Param("userId") Integer userId); + + @Query("SELECT COUNT(e) FROM ExperimentItem e WHERE e.tradeStatus = 'PROGRESS'") + int countByTradeStatusProgress(); + + @Query("SELECT e FROM ExperimentItem e WHERE e.stock.id = :stockId AND e.buyAy = :today") + Optional findExperimentItemByStockIdAndBuyAt(@Param("stockId") Integer stockId, @Param("today") LocalDate today); +} diff --git a/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java b/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java new file mode 100644 index 0000000..1e26e6a --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java @@ -0,0 +1,185 @@ +package com.fund.stockProject.experiment.service; + +import com.fund.stockProject.auth.entity.User; +import com.fund.stockProject.auth.repository.UserRepository; +import com.fund.stockProject.experiment.dto.ExperimentSimpleResponse; +import com.fund.stockProject.experiment.dto.ExperimentStatusResponse; +import com.fund.stockProject.experiment.dto.ProgressExperimentItemResponse; +import com.fund.stockProject.experiment.entity.ExperimentItem; +import com.fund.stockProject.experiment.repository.ExperimentRepository; +import com.fund.stockProject.score.repository.ScoreRepository; +import com.fund.stockProject.stock.domain.COUNTRY; +import com.fund.stockProject.stock.domain.EXCHANGENUM; +import com.fund.stockProject.stock.dto.response.StockInfoResponse; +import com.fund.stockProject.stock.entity.Stock; +import com.fund.stockProject.stock.repository.StockRepository; +import com.fund.stockProject.stock.service.SecurityService; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +@Service +@RequiredArgsConstructor +public class ExperimentService { + + private final ExperimentRepository experimentRepository; + private final StockRepository stockRepository; + private final UserRepository userRepository; + private final ScoreRepository scoreRepository; + private final SecurityService securityService; + + public Mono getExperimentStatus(final Integer userId) { + final List experimentItemsByUserId = experimentRepository.findExperimentItemsByUserId(userId); + + if (experimentItemsByUserId.isEmpty()) { + return Mono.empty(); + } + + final List progressExperimentItems = new ArrayList<>(); + + for (final ExperimentItem experimentItem : experimentItemsByUserId) { + final Optional bySymbol = stockRepository.findBySymbol(experimentItem.getStock().getSymbol()); + + if (bySymbol.isEmpty()) { + throw new NoSuchElementException("No Stock Found"); + } + + final Stock stock = bySymbol.get(); + + StockInfoResponse stockInfoKorea = securityService.getSecurityStockInfoKorea( + stock.getId(), + stock.getSymbolName(), + stock.getSecurityName(), + stock.getSymbol(), + stock.getExchangeNum(), + getCountryFromExchangeNum(stock.getExchangeNum()) + ).block(); + + progressExperimentItems.add(ProgressExperimentItemResponse + .builder() + .id(experimentItem.getId()) + .roi(experimentItem.getRoi()) + .buyAt(experimentItem.getBuyAt()) + .symbolName(stock.getSymbolName()) + .currentPrice(stockInfoKorea.getPrice()) + .diffPrice((stockInfoKorea.getPrice()) - experimentItem.getBuyPrice()) + .tradeStatus(experimentItem.getTradeStatus()) + .build()); + } + + final int countByTradeStatusCompleted = experimentRepository.countByTradeStatusProgress(); // 진행중인 실험 + + final double averageRoi = experimentItemsByUserId + .stream() + .mapToDouble(ExperimentItem::getRoi) // 각 ROI 값을 double로 추출 + .average() // OptionalDouble 반환 + .orElse(0.0); + + final long count = experimentItemsByUserId.stream() + .filter(p -> p.getSellPrice() - p.getBuyPrice() > 0).count(); // 모의투자에 성공한 종목 개수 + double successRate = ((double) count / experimentItemsByUserId.size()) * 100; + + final ExperimentStatusResponse portfolioStatusResponse = ExperimentStatusResponse. + builder() + .progressExperimentItems(progressExperimentItems) // 진행중인 실험 정보 + .avgRoi(averageRoi) // 평균 수익률 + .totalPaperTradeCount(experimentItemsByUserId.size()) // 총 실험 수 (전체 모의투자 개수) + .progressPaperTradeCount(countByTradeStatusCompleted) // 진행중인 실험 수 (진행중인 모의투자 개수) + .successRate(successRate) // 성공률 + .build(); + + return Mono.just(portfolioStatusResponse); + } + + private COUNTRY getCountryFromExchangeNum(EXCHANGENUM exchangenum) { + return List.of(EXCHANGENUM.KOSPI, EXCHANGENUM.KOSDAQ, EXCHANGENUM.KOREAN_ETF) + .contains(exchangenum) ? COUNTRY.KOREA : COUNTRY.OVERSEA; + } + + public Mono buyExperimentItem(final Integer userId, final Integer stockId, String country) { + final Optional stockById = stockRepository.findStockById(stockId); + final Optional userById = userRepository.findById(userId); + + if (stockById.isEmpty() || userById.isEmpty()) { + return Mono.empty(); + } + + final Stock stock = stockById.get(); + final User user = userById.get(); + + LocalDateTime now = LocalDateTime.now(); + LocalDate today = now.toLocalDate(); + LocalTime current = now.toLocalTime(); + + final Optional experimentItemByStockIdAndBuyAt = experimentRepository.findExperimentItemByStockIdAndBuyAt(stockId, today); + + if(experimentItemByStockIdAndBuyAt.isPresent()){ + return Mono.just( + ExperimentSimpleResponse.builder() + .message("같은 종목 중복 구매") + .success(false) + .price(0.0d) + .build() + ); + } + + LocalTime domesticUpdateTime = LocalTime.of(17, 00); + LocalTime overseasUpdateTime = LocalTime.of(6, 0); + + Double price = 0.0d; + + final Mono securityStockInfoKorea = securityService.getSecurityStockInfoKorea( + stock.getId(), stock.getSymbolName(), stock.getSecurityName(), stock.getSymbol(), stock.getExchangeNum(), getCountryFromExchangeNum(stock.getExchangeNum()) + ); + + if(securityStockInfoKorea.blockOptional().isEmpty()){ + return Mono.empty(); + } + + final StockInfoResponse stockInfoResponse = securityStockInfoKorea.block(); + + if (stockInfoResponse.getCountry().equals(COUNTRY.KOREA)) { + if (current.isBefore(domesticUpdateTime)) { + // 17:00 이전 → 전일 종가 + price = stockInfoResponse.getYesterdayPrice(); + } else { + // 17:00 이후 → 당일 종가 + price = stockInfoResponse.getPrice(); + } + } else { + if (current.isBefore(overseasUpdateTime)) { + // 06:00 이전 → 전일(미국시장 종가) → KST 기준 ‘전날’ + price = stockInfoResponse.getYesterdayPrice(); + } else { + // 06:00 이후 → 당일(미국시장 전날 종가) → KST 기준 ‘오늘’ + price = stockInfoResponse.getTodayPrice(); + } + } + + final ExperimentItem experimentItem = ExperimentItem + .builder() + .user(user) + .stock(stock) + .tradeStatus("PROGRESS") + .buyAt(LocalDateTime.now()) + .buyPrice(price) + .build(); + + experimentRepository.save(experimentItem); + + return Mono.just( + ExperimentSimpleResponse.builder() + .message("모의 매수 성공") + .success(true) + .price(price) + .build() + ); + } +} diff --git a/src/main/java/com/fund/stockProject/stock/dto/response/StockInfoResponse.java b/src/main/java/com/fund/stockProject/stock/dto/response/StockInfoResponse.java index a70f78c..9cb52e8 100644 --- a/src/main/java/com/fund/stockProject/stock/dto/response/StockInfoResponse.java +++ b/src/main/java/com/fund/stockProject/stock/dto/response/StockInfoResponse.java @@ -30,4 +30,8 @@ public class StockInfoResponse { private Double priceDiff; private Double priceDiffPerCent; + + private Double yesterdayPrice; + + private Double todayPrice; } diff --git a/src/main/java/com/fund/stockProject/stock/service/SecurityService.java b/src/main/java/com/fund/stockProject/stock/service/SecurityService.java index 29ad548..f6eebe4 100644 --- a/src/main/java/com/fund/stockProject/stock/service/SecurityService.java +++ b/src/main/java/com/fund/stockProject/stock/service/SecurityService.java @@ -87,7 +87,8 @@ private Mono parseFStockInfoKorea(String response, Integer id stockInfoResponse.setCountry(country); stockInfoResponse.setPrice(outputNode.get("stck_prpr").asDouble()); stockInfoResponse.setPriceDiff(outputNode.get("prdy_vrss").asDouble()); - stockInfoResponse.setPriceDiffPerCent(outputNode.get("prdy_ctrt").asDouble()); + stockInfoResponse.setYesterdayPrice(outputNode.get("bfdy_clpr").asDouble()); + stockInfoResponse.setTodayPrice(outputNode.get("thdt_clpr").asDouble()); } return Mono.just(stockInfoResponse); @@ -110,6 +111,8 @@ private Mono parseFStockInfoOversea(String response, Integer stockInfoResponse.setSymbol(symbol); stockInfoResponse.setExchangeNum(exchangenum); stockInfoResponse.setPrice(outputNode.get("last").asDouble()); + stockInfoResponse.setYesterdayPrice(outputNode.get("last").asDouble()); + stockInfoResponse.setTodayPrice(outputNode.get("base").asDouble()); // 해외는 diff가 절대값이므로 절대값에 따라 음수로 변경 if(outputNode.get("rate").asDouble() < 0) { stockInfoResponse.setPriceDiff(outputNode.get("diff").asDouble() * -1); From d381084fc7ae0ccfbf914b98971d6a61ced54c7f Mon Sep 17 00:00:00 2001 From: jerome Date: Sun, 27 Jul 2025 17:36:10 +0900 Subject: [PATCH 02/11] =?UTF-8?q?REFACTOR(#129):=20=EB=AA=A8=EC=9D=98=20?= =?UTF-8?q?=ED=88=AC=EC=9E=90=20=ED=98=84=ED=99=A9,=20=EC=A2=85=EB=AA=A9?= =?UTF-8?q?=20=EB=A7=A4=EC=88=98=20API=20=EC=9C=A0=EC=A0=80=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../experiment/controller/ExperimentController.java | 9 +++++---- .../experiment/repository/ExperimentRepository.java | 4 ++-- .../experiment/service/ExperimentService.java | 9 +++++---- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java b/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java index d810e07..5815bfa 100644 --- a/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java +++ b/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java @@ -3,6 +3,7 @@ import com.fund.stockProject.experiment.dto.ExperimentSimpleResponse; import com.fund.stockProject.experiment.dto.ExperimentStatusResponse; import com.fund.stockProject.experiment.service.ExperimentService; +import com.fund.stockProject.security.principle.CustomUserDetails; import com.fund.stockProject.stock.domain.COUNTRY; import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; @@ -24,13 +25,13 @@ public class ExperimentController { @GetMapping("/status") @Operation(summary = "실험(모의 매수) 현황 API", description = "실험(모의 매수) 현황 조회") - public ResponseEntity> getExperimentStatus(final @PathVariable Integer userId) { - return ResponseEntity.ok().body(experimentService.getExperimentStatus(userId)); + public ResponseEntity> getExperimentStatus(@AuthenticationPrincipal CustomUserDetails customUserDetails) { + return ResponseEntity.ok().body(experimentService.getExperimentStatus(customUserDetails)); } @PostMapping("/{id}/buy/{country}") @Operation(summary = "실험(모의 매수) 종목 매수 API", description = "실험(모의 매수) 종목 매수") - public ResponseEntity> buyExperimentItem(final @PathVariable Integer userId, final @PathVariable("id") Integer stockId, final @PathVariable("country") String country) { - return ResponseEntity.ok().body(experimentService.buyExperimentItem(userId, stockId, country)); + public ResponseEntity> buyExperimentItem(@AuthenticationPrincipal CustomUserDetails customUserDetails, final @PathVariable("id") Integer stockId, final @PathVariable("country") String country) { + return ResponseEntity.ok().body(experimentService.buyExperimentItem(customUserDetails, stockId, country)); } } diff --git a/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java b/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java index c75793c..e1d1fc8 100644 --- a/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java +++ b/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java @@ -11,8 +11,8 @@ @Repository public interface ExperimentRepository extends JpaRepository { - @Query("SELECT e FROM ExperimentItem e WHERE e.userId = :userId") - List findExperimentItemsByUserId(@Param("userId") Integer userId); + @Query("SELECT e FROM ExperimentItem e WHERE e.email = :email") + List findExperimentItemsByEmail(@Param("email") String email); @Query("SELECT COUNT(e) FROM ExperimentItem e WHERE e.tradeStatus = 'PROGRESS'") int countByTradeStatusProgress(); diff --git a/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java b/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java index 1e26e6a..8f45751 100644 --- a/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java +++ b/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java @@ -8,6 +8,7 @@ import com.fund.stockProject.experiment.entity.ExperimentItem; import com.fund.stockProject.experiment.repository.ExperimentRepository; import com.fund.stockProject.score.repository.ScoreRepository; +import com.fund.stockProject.security.principle.CustomUserDetails; import com.fund.stockProject.stock.domain.COUNTRY; import com.fund.stockProject.stock.domain.EXCHANGENUM; import com.fund.stockProject.stock.dto.response.StockInfoResponse; @@ -35,8 +36,8 @@ public class ExperimentService { private final ScoreRepository scoreRepository; private final SecurityService securityService; - public Mono getExperimentStatus(final Integer userId) { - final List experimentItemsByUserId = experimentRepository.findExperimentItemsByUserId(userId); + public Mono getExperimentStatus(final CustomUserDetails customUserDetails) { + final List experimentItemsByUserId = experimentRepository.findExperimentItemsByEmail(customUserDetails.getEmail()); if (experimentItemsByUserId.isEmpty()) { return Mono.empty(); @@ -103,9 +104,9 @@ private COUNTRY getCountryFromExchangeNum(EXCHANGENUM exchangenum) { .contains(exchangenum) ? COUNTRY.KOREA : COUNTRY.OVERSEA; } - public Mono buyExperimentItem(final Integer userId, final Integer stockId, String country) { + public Mono buyExperimentItem(final CustomUserDetails customUserDetails, final Integer stockId, String country) { final Optional stockById = stockRepository.findStockById(stockId); - final Optional userById = userRepository.findById(userId); + final Optional userById = userRepository.findByEmail(customUserDetails.getEmail()); if (stockById.isEmpty() || userById.isEmpty()) { return Mono.empty(); From 3c056bfd2d5c31f5ab457d59922afd8b2fad3ee1 Mon Sep 17 00:00:00 2001 From: jerome Date: Sun, 27 Jul 2025 18:42:00 +0900 Subject: [PATCH 03/11] =?UTF-8?q?REFACTOR(#129):=20=EB=AA=A8=EC=9D=98=20?= =?UTF-8?q?=ED=88=AC=EC=9E=90=20=EC=9E=90=EB=8F=99=20=EB=A7=A4=EB=8F=84=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../experiment/entity/ExperimentItem.java | 8 ++- .../repository/ExperimentRepository.java | 3 ++ .../experiment/service/ExperimentService.java | 53 ++++++++++++++++--- .../scheduler/AutoSellingScheduler.java | 38 +++++++++++++ 4 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/fund/stockProject/global/scheduler/AutoSellingScheduler.java diff --git a/src/main/java/com/fund/stockProject/experiment/entity/ExperimentItem.java b/src/main/java/com/fund/stockProject/experiment/entity/ExperimentItem.java index e545aea..39858f6 100644 --- a/src/main/java/com/fund/stockProject/experiment/entity/ExperimentItem.java +++ b/src/main/java/com/fund/stockProject/experiment/entity/ExperimentItem.java @@ -47,11 +47,17 @@ public class ExperimentItem { private Double buyPrice; @Column(nullable = false) - private Integer sellPrice; + private Double sellPrice; @Column(nullable = false) private Double roi; @Column(nullable = false) private String tradeStatus; + + public void updateAutoSellResult(Double sellPrice, String tradeStatus, LocalDateTime sellAt) { + this.sellPrice = sellPrice; + this.tradeStatus = tradeStatus; + this.sellAt = sellAt; + } } diff --git a/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java b/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java index e1d1fc8..5fc3a5c 100644 --- a/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java +++ b/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java @@ -19,4 +19,7 @@ public interface ExperimentRepository extends JpaRepository findExperimentItemByStockIdAndBuyAt(@Param("stockId") Integer stockId, @Param("today") LocalDate today); + + @Query("SELECT e FROM experiment_item E WHERE DATE(e.buy_at) = CURDATE() - INTERVAL 5 DAY;") + List findExperimentItemsAfter5BusinessDays(); } diff --git a/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java b/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java index 8f45751..2d50810 100644 --- a/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java +++ b/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java @@ -24,6 +24,7 @@ import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import reactor.core.publisher.Mono; @Service @@ -36,8 +37,10 @@ public class ExperimentService { private final ScoreRepository scoreRepository; private final SecurityService securityService; - public Mono getExperimentStatus(final CustomUserDetails customUserDetails) { - final List experimentItemsByUserId = experimentRepository.findExperimentItemsByEmail(customUserDetails.getEmail()); + public Mono getExperimentStatus( + final CustomUserDetails customUserDetails) { + final List experimentItemsByUserId = experimentRepository.findExperimentItemsByEmail( + customUserDetails.getEmail()); if (experimentItemsByUserId.isEmpty()) { return Mono.empty(); @@ -46,7 +49,8 @@ public Mono getExperimentStatus(final CustomUserDetail final List progressExperimentItems = new ArrayList<>(); for (final ExperimentItem experimentItem : experimentItemsByUserId) { - final Optional bySymbol = stockRepository.findBySymbol(experimentItem.getStock().getSymbol()); + final Optional bySymbol = stockRepository.findBySymbol( + experimentItem.getStock().getSymbol()); if (bySymbol.isEmpty()) { throw new NoSuchElementException("No Stock Found"); @@ -104,7 +108,8 @@ private COUNTRY getCountryFromExchangeNum(EXCHANGENUM exchangenum) { .contains(exchangenum) ? COUNTRY.KOREA : COUNTRY.OVERSEA; } - public Mono buyExperimentItem(final CustomUserDetails customUserDetails, final Integer stockId, String country) { + public Mono buyExperimentItem( + final CustomUserDetails customUserDetails, final Integer stockId, String country) { final Optional stockById = stockRepository.findStockById(stockId); final Optional userById = userRepository.findByEmail(customUserDetails.getEmail()); @@ -119,9 +124,10 @@ public Mono buyExperimentItem(final CustomUserDetails LocalDate today = now.toLocalDate(); LocalTime current = now.toLocalTime(); - final Optional experimentItemByStockIdAndBuyAt = experimentRepository.findExperimentItemByStockIdAndBuyAt(stockId, today); + final Optional experimentItemByStockIdAndBuyAt = experimentRepository.findExperimentItemByStockIdAndBuyAt( + stockId, today); - if(experimentItemByStockIdAndBuyAt.isPresent()){ + if (experimentItemByStockIdAndBuyAt.isPresent()) { return Mono.just( ExperimentSimpleResponse.builder() .message("같은 종목 중복 구매") @@ -137,10 +143,11 @@ public Mono buyExperimentItem(final CustomUserDetails Double price = 0.0d; final Mono securityStockInfoKorea = securityService.getSecurityStockInfoKorea( - stock.getId(), stock.getSymbolName(), stock.getSecurityName(), stock.getSymbol(), stock.getExchangeNum(), getCountryFromExchangeNum(stock.getExchangeNum()) + stock.getId(), stock.getSymbolName(), stock.getSecurityName(), stock.getSymbol(), + stock.getExchangeNum(), getCountryFromExchangeNum(stock.getExchangeNum()) ); - if(securityStockInfoKorea.blockOptional().isEmpty()){ + if (securityStockInfoKorea.blockOptional().isEmpty()) { return Mono.empty(); } @@ -183,4 +190,34 @@ public Mono buyExperimentItem(final CustomUserDetails .build() ); } + + @Transactional(readOnly = true) + public List findExperimentItemAfter5BusinessDays() { + final List experimentItemsAfter5BusinessDays = experimentRepository.findExperimentItemsAfter5BusinessDays(); + + if (experimentItemsAfter5BusinessDays.isEmpty()) { + return new ArrayList<>(); + } + + return experimentItemsAfter5BusinessDays; + } + + @Transactional + public void updateAutoSellStockStatus(ExperimentItem experimentItem) { + try { + final Stock stock = experimentItem.getStock(); + + final Mono securityStockInfoKorea = securityService.getSecurityStockInfoKorea( + stock.getId(), stock.getSymbolName(), stock.getSecurityName(), stock.getSymbol(), + stock.getExchangeNum(), getCountryFromExchangeNum(stock.getExchangeNum())); + + if (securityStockInfoKorea.blockOptional().isPresent()) { + final Double price = securityStockInfoKorea.block().getPrice(); + experimentItem.updateAutoSellResult(price, "COMPLETE", LocalDateTime.now()); + } + + }catch (Exception e){ + System.err.println("Failed to autoSell"); + } + } } diff --git a/src/main/java/com/fund/stockProject/global/scheduler/AutoSellingScheduler.java b/src/main/java/com/fund/stockProject/global/scheduler/AutoSellingScheduler.java new file mode 100644 index 0000000..709d4c9 --- /dev/null +++ b/src/main/java/com/fund/stockProject/global/scheduler/AutoSellingScheduler.java @@ -0,0 +1,38 @@ +package com.fund.stockProject.global.scheduler; + +import com.fund.stockProject.experiment.entity.ExperimentItem; +import com.fund.stockProject.experiment.service.ExperimentService; +import com.fund.stockProject.score.entity.Score; +import com.fund.stockProject.stock.domain.COUNTRY; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AutoSellingScheduler { + + private ExperimentService experimentService; + + /** + * 모의투자 자동매매 스케줄러 + */ + @Scheduled(cron = "0 30 23 * * ?", zone = "Asia/Seoul") // 6시에 실행 + public void processAutoSell() { + LocalDate today = LocalDate.now(); + + // 모의 매수 5일차 종목 조회 + final List experimentItemsAfter5BusinessDays = experimentService.findExperimentItemAfter5BusinessDays(); + + for (ExperimentItem experimentItem : experimentItemsAfter5BusinessDays) { + try { + experimentService.updateAutoSellStockStatus(experimentItem); + } catch (Exception e) { + System.err.println("Error processing autoSell " + experimentItem.getId() + " - " + e.getMessage()); + } + } + + } +} From c3b06813f3fc07dd9e531b2546fad3522bd438c2 Mon Sep 17 00:00:00 2001 From: jerome Date: Sun, 27 Jul 2025 19:01:01 +0900 Subject: [PATCH 04/11] =?UTF-8?q?REFACTOR(#129):=20=EB=AA=A8=EC=9D=98=20?= =?UTF-8?q?=ED=88=AC=EC=9E=90=20=EC=9E=90=EB=8F=99=20=EB=A7=A4=EB=8F=84=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20roi=20=EA=B4=80=EB=A0=A8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fund/stockProject/experiment/entity/ExperimentItem.java | 3 ++- .../stockProject/experiment/service/ExperimentService.java | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/fund/stockProject/experiment/entity/ExperimentItem.java b/src/main/java/com/fund/stockProject/experiment/entity/ExperimentItem.java index 39858f6..a9ccd31 100644 --- a/src/main/java/com/fund/stockProject/experiment/entity/ExperimentItem.java +++ b/src/main/java/com/fund/stockProject/experiment/entity/ExperimentItem.java @@ -55,9 +55,10 @@ public class ExperimentItem { @Column(nullable = false) private String tradeStatus; - public void updateAutoSellResult(Double sellPrice, String tradeStatus, LocalDateTime sellAt) { + public void updateAutoSellResult(Double sellPrice, String tradeStatus, LocalDateTime sellAt, Double roi) { this.sellPrice = sellPrice; this.tradeStatus = tradeStatus; this.sellAt = sellAt; + this.roi = roi; } } diff --git a/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java b/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java index 2d50810..1e6f141 100644 --- a/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java +++ b/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java @@ -212,8 +212,9 @@ public void updateAutoSellStockStatus(ExperimentItem experimentItem) { stock.getExchangeNum(), getCountryFromExchangeNum(stock.getExchangeNum())); if (securityStockInfoKorea.blockOptional().isPresent()) { - final Double price = securityStockInfoKorea.block().getPrice(); - experimentItem.updateAutoSellResult(price, "COMPLETE", LocalDateTime.now()); + final Double price = securityStockInfoKorea.block().getTodayPrice(); + Double roi = ((experimentItem.getBuyPrice() - price) % experimentItem.getBuyPrice()) * 100; + experimentItem.updateAutoSellResult(price, "COMPLETE", LocalDateTime.now(), roi); } }catch (Exception e){ From c4f9c1cf1e4bc704c1e541a0eca93a26c13179e6 Mon Sep 17 00:00:00 2001 From: jerome Date: Sun, 27 Jul 2025 19:01:46 +0900 Subject: [PATCH 05/11] =?UTF-8?q?REFACTOR(#129):=20=EC=A3=BC=EC=8B=9D=20?= =?UTF-8?q?=EC=A2=85=EB=AA=A9=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fund/stockProject/stock/service/SecurityService.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/fund/stockProject/stock/service/SecurityService.java b/src/main/java/com/fund/stockProject/stock/service/SecurityService.java index f6eebe4..9dd9248 100644 --- a/src/main/java/com/fund/stockProject/stock/service/SecurityService.java +++ b/src/main/java/com/fund/stockProject/stock/service/SecurityService.java @@ -87,8 +87,8 @@ private Mono parseFStockInfoKorea(String response, Integer id stockInfoResponse.setCountry(country); stockInfoResponse.setPrice(outputNode.get("stck_prpr").asDouble()); stockInfoResponse.setPriceDiff(outputNode.get("prdy_vrss").asDouble()); - stockInfoResponse.setYesterdayPrice(outputNode.get("bfdy_clpr").asDouble()); - stockInfoResponse.setTodayPrice(outputNode.get("thdt_clpr").asDouble()); + stockInfoResponse.setYesterdayPrice(outputNode.get("bfdy_clpr").asDouble()); // 전일종가 + stockInfoResponse.setTodayPrice(outputNode.get("thdt_clpr").asDouble()); // 당일종가 } return Mono.just(stockInfoResponse); @@ -111,8 +111,8 @@ private Mono parseFStockInfoOversea(String response, Integer stockInfoResponse.setSymbol(symbol); stockInfoResponse.setExchangeNum(exchangenum); stockInfoResponse.setPrice(outputNode.get("last").asDouble()); - stockInfoResponse.setYesterdayPrice(outputNode.get("last").asDouble()); - stockInfoResponse.setTodayPrice(outputNode.get("base").asDouble()); + stockInfoResponse.setYesterdayPrice(outputNode.get("last").asDouble()); // 전일종가 + stockInfoResponse.setTodayPrice(outputNode.get("base").asDouble()); // 당일종가 // 해외는 diff가 절대값이므로 절대값에 따라 음수로 변경 if(outputNode.get("rate").asDouble() < 0) { stockInfoResponse.setPriceDiff(outputNode.get("diff").asDouble() * -1); From bcc88b43df95a2aff8caa5ab87bf341f4de7c463 Mon Sep 17 00:00:00 2001 From: jerome Date: Sun, 27 Jul 2025 19:21:44 +0900 Subject: [PATCH 06/11] =?UTF-8?q?REFACTOR(#129):=20=EB=AA=A8=EC=9D=98=20?= =?UTF-8?q?=ED=88=AC=EC=9E=90=20=ED=98=84=ED=99=A9=20=EC=8B=A4=ED=97=98=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EA=B4=80=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...e.java => ExperimentItemInfoResponse.java} | 2 +- .../dto/ExperimentStatusResponse.java | 4 +- .../experiment/service/ExperimentService.java | 39 ++++++++++++++----- 3 files changed, 34 insertions(+), 11 deletions(-) rename src/main/java/com/fund/stockProject/experiment/dto/{ProgressExperimentItemResponse.java => ExperimentItemInfoResponse.java} (90%) diff --git a/src/main/java/com/fund/stockProject/experiment/dto/ProgressExperimentItemResponse.java b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentItemInfoResponse.java similarity index 90% rename from src/main/java/com/fund/stockProject/experiment/dto/ProgressExperimentItemResponse.java rename to src/main/java/com/fund/stockProject/experiment/dto/ExperimentItemInfoResponse.java index e925fad..6e74a23 100644 --- a/src/main/java/com/fund/stockProject/experiment/dto/ProgressExperimentItemResponse.java +++ b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentItemInfoResponse.java @@ -6,7 +6,7 @@ @Getter @Builder -public class ProgressExperimentItemResponse { +public class ExperimentItemInfoResponse { private Integer id; diff --git a/src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusResponse.java b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusResponse.java index 2193653..06609f8 100644 --- a/src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusResponse.java +++ b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusResponse.java @@ -8,7 +8,9 @@ @Builder public class ExperimentStatusResponse { - private List progressExperimentItems; + private List progressExperimentItems; + + private List completeExperimentItems; private double avgRoi; // 평균수익률 diff --git a/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java b/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java index 1e6f141..87a75ec 100644 --- a/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java +++ b/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java @@ -4,7 +4,7 @@ import com.fund.stockProject.auth.repository.UserRepository; import com.fund.stockProject.experiment.dto.ExperimentSimpleResponse; import com.fund.stockProject.experiment.dto.ExperimentStatusResponse; -import com.fund.stockProject.experiment.dto.ProgressExperimentItemResponse; +import com.fund.stockProject.experiment.dto.ExperimentItemInfoResponse; import com.fund.stockProject.experiment.entity.ExperimentItem; import com.fund.stockProject.experiment.repository.ExperimentRepository; import com.fund.stockProject.score.repository.ScoreRepository; @@ -39,6 +39,7 @@ public class ExperimentService { public Mono getExperimentStatus( final CustomUserDetails customUserDetails) { + // 로그인한 유저 관련 모의 투자 정보 조회 final List experimentItemsByUserId = experimentRepository.findExperimentItemsByEmail( customUserDetails.getEmail()); @@ -46,7 +47,10 @@ public Mono getExperimentStatus( return Mono.empty(); } - final List progressExperimentItems = new ArrayList<>(); + // 진행중인 모의 투자 종목 + final List progressExperimentItemsInfo = new ArrayList<>(); + // 완료된 모의 투자 종목 + final List completeExperimentItemsInfo = new ArrayList<>(); for (final ExperimentItem experimentItem : experimentItemsByUserId) { final Optional bySymbol = stockRepository.findBySymbol( @@ -67,7 +71,22 @@ public Mono getExperimentStatus( getCountryFromExchangeNum(stock.getExchangeNum()) ).block(); - progressExperimentItems.add(ProgressExperimentItemResponse + if (experimentItem.getTradeStatus().equals("PROGRESS")) { + progressExperimentItemsInfo.add(ExperimentItemInfoResponse + .builder() + .id(experimentItem.getId()) + .roi(experimentItem.getRoi()) + .buyAt(experimentItem.getBuyAt()) + .symbolName(stock.getSymbolName()) + .currentPrice(stockInfoKorea.getPrice()) + .diffPrice((stockInfoKorea.getPrice()) - experimentItem.getBuyPrice()) + .tradeStatus(experimentItem.getTradeStatus()) + .build()); + + continue; + } + + completeExperimentItemsInfo.add(ExperimentItemInfoResponse .builder() .id(experimentItem.getId()) .roi(experimentItem.getRoi()) @@ -79,7 +98,7 @@ public Mono getExperimentStatus( .build()); } - final int countByTradeStatusCompleted = experimentRepository.countByTradeStatusProgress(); // 진행중인 실험 + final int countByTradeStatusCompleted = experimentRepository.countByTradeStatusProgress(); // 진행중인 실험 수 final double averageRoi = experimentItemsByUserId .stream() @@ -91,16 +110,17 @@ public Mono getExperimentStatus( .filter(p -> p.getSellPrice() - p.getBuyPrice() > 0).count(); // 모의투자에 성공한 종목 개수 double successRate = ((double) count / experimentItemsByUserId.size()) * 100; - final ExperimentStatusResponse portfolioStatusResponse = ExperimentStatusResponse. + final ExperimentStatusResponse experimentStatusResponse = ExperimentStatusResponse. builder() - .progressExperimentItems(progressExperimentItems) // 진행중인 실험 정보 + .progressExperimentItems(progressExperimentItemsInfo) // 진행중인 실험 정보 + .completeExperimentItems(completeExperimentItemsInfo) // 완료된 실험 정보 .avgRoi(averageRoi) // 평균 수익률 .totalPaperTradeCount(experimentItemsByUserId.size()) // 총 실험 수 (전체 모의투자 개수) .progressPaperTradeCount(countByTradeStatusCompleted) // 진행중인 실험 수 (진행중인 모의투자 개수) .successRate(successRate) // 성공률 .build(); - return Mono.just(portfolioStatusResponse); + return Mono.just(experimentStatusResponse); } private COUNTRY getCountryFromExchangeNum(EXCHANGENUM exchangenum) { @@ -213,11 +233,12 @@ public void updateAutoSellStockStatus(ExperimentItem experimentItem) { if (securityStockInfoKorea.blockOptional().isPresent()) { final Double price = securityStockInfoKorea.block().getTodayPrice(); - Double roi = ((experimentItem.getBuyPrice() - price) % experimentItem.getBuyPrice()) * 100; + Double roi = + ((experimentItem.getBuyPrice() - price) % experimentItem.getBuyPrice()) * 100; experimentItem.updateAutoSellResult(price, "COMPLETE", LocalDateTime.now(), roi); } - }catch (Exception e){ + } catch (Exception e) { System.err.println("Failed to autoSell"); } } From 85e0178cff29944b194386aa3843de9e2ab12833 Mon Sep 17 00:00:00 2001 From: jerome Date: Sun, 27 Jul 2025 22:12:58 +0900 Subject: [PATCH 07/11] =?UTF-8?q?REFACTOR(#129):=20=EA=B4=80=EC=8B=AC=20?= =?UTF-8?q?=EC=A2=85=EB=AA=A9=20=EA=B2=80=EC=83=89,=20=EA=B4=80=EC=8B=AC?= =?UTF-8?q?=20=EC=A2=85=EB=AA=A9=20=EA=B2=80=EC=83=89=EC=96=B4=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EC=99=84=EC=84=B1=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ExperimentController.java | 17 ++++++++ .../experiment/service/ExperimentService.java | 41 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java b/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java index 5815bfa..a6ad4f1 100644 --- a/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java +++ b/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java @@ -5,7 +5,10 @@ import com.fund.stockProject.experiment.service.ExperimentService; import com.fund.stockProject.security.principle.CustomUserDetails; import com.fund.stockProject.stock.domain.COUNTRY; +import com.fund.stockProject.stock.dto.response.StockInfoResponse; +import com.fund.stockProject.stock.dto.response.StockSearchResponse; import io.swagger.v3.oas.annotations.Operation; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -13,6 +16,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import reactor.core.publisher.Mono; @@ -34,4 +38,17 @@ public ResponseEntity> getExperimentStatus(@Authe public ResponseEntity> buyExperimentItem(@AuthenticationPrincipal CustomUserDetails customUserDetails, final @PathVariable("id") Integer stockId, final @PathVariable("country") String country) { return ResponseEntity.ok().body(experimentService.buyExperimentItem(customUserDetails, stockId, country)); } + + @GetMapping("/search/{searchKeyword}/{country}") + @Operation(summary = "관심 종목 검색 API", description = "주식 종목 및 인간지표 데이터 검색") + public ResponseEntity> searchStockBySymbolName(final @PathVariable String searchKeyword, final @PathVariable String country) { + return ResponseEntity.ok().body(experimentService.searchStockBySymbolName(searchKeyword, country)); + } + + @GetMapping("/autocomplete") + @Operation(summary = "관심 종목 검색어 자동완성 API", description = "관심 종목 검색어 자동완성") + public ResponseEntity> autocompleteKeyword(final @RequestParam String keyword) { + return ResponseEntity.ok().body(experimentService.autoCompleteKeyword(keyword)); + } + } diff --git a/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java b/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java index 87a75ec..3912bfb 100644 --- a/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java +++ b/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java @@ -12,7 +12,9 @@ import com.fund.stockProject.stock.domain.COUNTRY; import com.fund.stockProject.stock.domain.EXCHANGENUM; import com.fund.stockProject.stock.dto.response.StockInfoResponse; +import com.fund.stockProject.stock.dto.response.StockSearchResponse; import com.fund.stockProject.stock.entity.Stock; +import com.fund.stockProject.stock.repository.StockQueryRepository; import com.fund.stockProject.stock.repository.StockRepository; import com.fund.stockProject.stock.service.SecurityService; import java.time.LocalDate; @@ -22,6 +24,7 @@ import java.util.List; import java.util.NoSuchElementException; import java.util.Optional; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -36,6 +39,7 @@ public class ExperimentService { private final UserRepository userRepository; private final ScoreRepository scoreRepository; private final SecurityService securityService; + private final StockQueryRepository stockQueryRepository; public Mono getExperimentStatus( final CustomUserDetails customUserDetails) { @@ -242,4 +246,41 @@ public void updateAutoSellStockStatus(ExperimentItem experimentItem) { System.err.println("Failed to autoSell"); } } + + public Mono searchStockBySymbolName(final String searchKeyword, final String country) { + List koreaExchanges = List.of(EXCHANGENUM.KOSPI, EXCHANGENUM.KOSDAQ, EXCHANGENUM.KOREAN_ETF); + List overseaExchanges = List.of(EXCHANGENUM.NAS, EXCHANGENUM.NYS, EXCHANGENUM.AMS); + + final Optional bySymbolNameAndCountryWithEnums = stockRepository.findBySearchKeywordAndCountryWithEnums( + searchKeyword, country, koreaExchanges, overseaExchanges); + + if (bySymbolNameAndCountryWithEnums.isPresent()) { + final Stock stock = bySymbolNameAndCountryWithEnums.get(); + + return securityService.getSecurityStockInfoKorea(stock.getId(), stock.getSymbolName(), + stock.getSecurityName(), stock.getSymbol(), stock.getExchangeNum(), + getCountryFromExchangeNum(stock.getExchangeNum())); + } + + return Mono.empty(); + } + + public List autoCompleteKeyword(String keyword) { + final List stocks = stockQueryRepository.autocompleteKeyword(keyword); + + if (stocks.isEmpty()) { + return null; + } + + return stocks.stream() + .map(stock -> StockSearchResponse.builder() + .stockId(stock.getId()) + .symbol(stock.getSymbol()) + .symbolName(stock.getSymbolName()) + .securityName(stock.getSecurityName()) + .exchangeNum(stock.getExchangeNum()) + .country(getCountryFromExchangeNum(stock.getExchangeNum())) + .build()) + .collect(Collectors.toList()); + } } From 2bcdf6d1ce5a9c08126f8a635e2c3509f79142ac Mon Sep 17 00:00:00 2001 From: jerome Date: Tue, 29 Jul 2025 23:08:37 +0900 Subject: [PATCH 08/11] =?UTF-8?q?REFACTOR(#129):=20=EB=AA=A8=EC=9D=98=20?= =?UTF-8?q?=ED=88=AC=EC=9E=90=20=EB=AA=A8=EC=9D=98=20=EB=A7=A4=EC=88=98=20?= =?UTF-8?q?API=20=EC=A2=85=EA=B0=80=20=EA=B2=B0=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=EB=8F=99=EC=9D=BC=20=EC=9D=BC=EC=9E=90=20=EC=A2=85=EB=AA=A9=20?= =?UTF-8?q?=EB=A7=A4=EC=88=98=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ExperimentController.java | 1 + .../repository/ExperimentRepository.java | 4 + .../experiment/service/ExperimentService.java | 79 +++++++++---------- 3 files changed, 44 insertions(+), 40 deletions(-) diff --git a/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java b/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java index a6ad4f1..ee312fb 100644 --- a/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java +++ b/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java @@ -51,4 +51,5 @@ public ResponseEntity> autocompleteKeyword(final @Requ return ResponseEntity.ok().body(experimentService.autoCompleteKeyword(keyword)); } + } diff --git a/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java b/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java index 5fc3a5c..23ee7b0 100644 --- a/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java +++ b/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java @@ -2,6 +2,7 @@ import com.fund.stockProject.experiment.entity.ExperimentItem; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -20,6 +21,9 @@ public interface ExperimentRepository extends JpaRepository findExperimentItemByStockIdAndBuyAt(@Param("stockId") Integer stockId, @Param("today") LocalDate today); + @Query("SELECT e FROM ExperimentItem e WHERE e.stock.id = :stockId AND e.buyAt BETWEEN :start AND :end") + Optional findExperimentItemByStockIdAndBuyAtBetween(@Param("stockId") Integer stockId, @Param("start") LocalDateTime start, @Param("end") LocalDateTime end); + @Query("SELECT e FROM experiment_item E WHERE DATE(e.buy_at) = CURDATE() - INTERVAL 5 DAY;") List findExperimentItemsAfter5BusinessDays(); } diff --git a/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java b/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java index 3912bfb..178e248 100644 --- a/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java +++ b/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java @@ -132,8 +132,7 @@ private COUNTRY getCountryFromExchangeNum(EXCHANGENUM exchangenum) { .contains(exchangenum) ? COUNTRY.KOREA : COUNTRY.OVERSEA; } - public Mono buyExperimentItem( - final CustomUserDetails customUserDetails, final Integer stockId, String country) { + public Mono buyExperimentItem(final CustomUserDetails customUserDetails, final Integer stockId, String country) { final Optional stockById = stockRepository.findStockById(stockId); final Optional userById = userRepository.findByEmail(customUserDetails.getEmail()); @@ -145,31 +144,14 @@ public Mono buyExperimentItem( final User user = userById.get(); LocalDateTime now = LocalDateTime.now(); - LocalDate today = now.toLocalDate(); LocalTime current = now.toLocalTime(); - - final Optional experimentItemByStockIdAndBuyAt = experimentRepository.findExperimentItemByStockIdAndBuyAt( - stockId, today); - - if (experimentItemByStockIdAndBuyAt.isPresent()) { - return Mono.just( - ExperimentSimpleResponse.builder() - .message("같은 종목 중복 구매") - .success(false) - .price(0.0d) - .build() - ); - } - - LocalTime domesticUpdateTime = LocalTime.of(17, 00); + LocalDate today = now.toLocalDate(); + LocalTime koreaUpdateTime = LocalTime.of(17, 0); LocalTime overseasUpdateTime = LocalTime.of(6, 0); - Double price = 0.0d; - final Mono securityStockInfoKorea = securityService.getSecurityStockInfoKorea( - stock.getId(), stock.getSymbolName(), stock.getSecurityName(), stock.getSymbol(), - stock.getExchangeNum(), getCountryFromExchangeNum(stock.getExchangeNum()) - ); + final Mono securityStockInfoKorea = securityService.getSecurityStockInfoKorea(stock.getId(), stock.getSymbolName(), + stock.getSecurityName(), stock.getSymbol(), stock.getExchangeNum(), getCountryFromExchangeNum(stock.getExchangeNum())); if (securityStockInfoKorea.blockOptional().isEmpty()) { return Mono.empty(); @@ -178,36 +160,53 @@ public Mono buyExperimentItem( final StockInfoResponse stockInfoResponse = securityStockInfoKorea.block(); if (stockInfoResponse.getCountry().equals(COUNTRY.KOREA)) { - if (current.isBefore(domesticUpdateTime)) { - // 17:00 이전 → 전일 종가 - price = stockInfoResponse.getYesterdayPrice(); - } else { - // 17:00 이후 → 당일 종가 - price = stockInfoResponse.getPrice(); + final Optional experimentItemByStockIdAndBuyAt = experimentRepository.findExperimentItemByStockIdAndBuyAt(stockId, today); + + // 하루에 같은 종목 중복 구매 불가 처리 + if (experimentItemByStockIdAndBuyAt.isPresent()) { + return Mono.just( + ExperimentSimpleResponse.builder() + .message("같은 종목 중복 구매") + .success(false) + .price(0.0d) + .build() + ); } + + // 종가 결정 + price = current.isBefore(koreaUpdateTime) ? stockInfoResponse.getYesterdayPrice() : stockInfoResponse.getTodayPrice(); } else { - if (current.isBefore(overseasUpdateTime)) { - // 06:00 이전 → 전일(미국시장 종가) → KST 기준 ‘전날’ - price = stockInfoResponse.getYesterdayPrice(); - } else { - // 06:00 이후 → 당일(미국시장 전날 종가) → KST 기준 ‘오늘’ - price = stockInfoResponse.getTodayPrice(); + // 해외 주식 로직 + LocalDateTime overseasStartTime = now.withHour(6).withMinute(0).withSecond(0).withNano(0); // 오늘 06:00 + LocalDateTime overseasPreviousDayTime = overseasStartTime.minusDays(1).plusMinutes(1); // 전날 06:01 + + // 해당 구간에 이미 매수한 경우 중복 매수 방지 + Optional existingItem = experimentRepository.findExperimentItemByStockIdAndBuyAtBetween(stockId, overseasPreviousDayTime, overseasStartTime); + + if (existingItem.isPresent()) { + return Mono.just(ExperimentSimpleResponse.builder() + .message("같은 종목 중복 구매") + .success(false) + .price(0.0d) + .build() + ); } + + // 종가 결정 + price = current.isBefore(overseasUpdateTime) ? stockInfoResponse.getYesterdayPrice() : stockInfoResponse.getTodayPrice(); } - final ExperimentItem experimentItem = ExperimentItem - .builder() + final ExperimentItem experimentItem = ExperimentItem.builder() .user(user) .stock(stock) .tradeStatus("PROGRESS") - .buyAt(LocalDateTime.now()) + .buyAt(now) .buyPrice(price) .build(); experimentRepository.save(experimentItem); - return Mono.just( - ExperimentSimpleResponse.builder() + return Mono.just(ExperimentSimpleResponse.builder() .message("모의 매수 성공") .success(true) .price(price) From d36b2636becb56b6888f8c91bf2c30573d990534 Mon Sep 17 00:00:00 2001 From: MuuiGong Date: Mon, 8 Sep 2025 00:09:44 +0900 Subject: [PATCH 09/11] =?UTF-8?q?FIX(#129):=20=EB=B9=84=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=20=EC=B2=98=EB=A6=AC=20=EC=8B=9C=20Securi?= =?UTF-8?q?tyContext=20=EC=9C=A0=EC=A7=80=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ExperimentController.java | 2 +- .../experiment/entity/ExperimentItem.java | 2 +- .../repository/ExperimentRepository.java | 8 +-- .../experiment/service/ExperimentService.java | 6 +- .../global/config/SecurityConfig.java | 29 +++------ .../filter/JwtAuthenticationFilter.java | 61 +++++++++++-------- 6 files changed, 54 insertions(+), 54 deletions(-) diff --git a/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java b/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java index ee312fb..7744dd3 100644 --- a/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java +++ b/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java @@ -25,7 +25,7 @@ @RequestMapping("/experiment") public class ExperimentController { - private ExperimentService experimentService; + private final ExperimentService experimentService; @GetMapping("/status") @Operation(summary = "실험(모의 매수) 현황 API", description = "실험(모의 매수) 현황 조회") diff --git a/src/main/java/com/fund/stockProject/experiment/entity/ExperimentItem.java b/src/main/java/com/fund/stockProject/experiment/entity/ExperimentItem.java index a9ccd31..17cc6a1 100644 --- a/src/main/java/com/fund/stockProject/experiment/entity/ExperimentItem.java +++ b/src/main/java/com/fund/stockProject/experiment/entity/ExperimentItem.java @@ -1,7 +1,7 @@ package com.fund.stockProject.experiment.entity; import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fund.stockProject.auth.entity.User; +import com.fund.stockProject.user.entity.User; import com.fund.stockProject.stock.entity.Stock; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java b/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java index 23ee7b0..d70d7ee 100644 --- a/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java +++ b/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java @@ -12,18 +12,18 @@ @Repository public interface ExperimentRepository extends JpaRepository { - @Query("SELECT e FROM ExperimentItem e WHERE e.email = :email") - List findExperimentItemsByEmail(@Param("email") String email); + @Query("SELECT e FROM ExperimentItem e WHERE e.user.email = :email") + List findExperimentItemsByUser_Email(@Param("email") String email); @Query("SELECT COUNT(e) FROM ExperimentItem e WHERE e.tradeStatus = 'PROGRESS'") int countByTradeStatusProgress(); - @Query("SELECT e FROM ExperimentItem e WHERE e.stock.id = :stockId AND e.buyAy = :today") + @Query("SELECT e FROM ExperimentItem e WHERE e.stock.id = :stockId AND e.buyAt = :today") Optional findExperimentItemByStockIdAndBuyAt(@Param("stockId") Integer stockId, @Param("today") LocalDate today); @Query("SELECT e FROM ExperimentItem e WHERE e.stock.id = :stockId AND e.buyAt BETWEEN :start AND :end") Optional findExperimentItemByStockIdAndBuyAtBetween(@Param("stockId") Integer stockId, @Param("start") LocalDateTime start, @Param("end") LocalDateTime end); - @Query("SELECT e FROM experiment_item E WHERE DATE(e.buy_at) = CURDATE() - INTERVAL 5 DAY;") + @Query("SELECT e FROM ExperimentItem e WHERE e.buyAt <= FUNCTION('DATE_SUB', CURRENT_DATE, 5)") List findExperimentItemsAfter5BusinessDays(); } diff --git a/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java b/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java index 178e248..7b9fef3 100644 --- a/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java +++ b/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java @@ -1,7 +1,7 @@ package com.fund.stockProject.experiment.service; -import com.fund.stockProject.auth.entity.User; -import com.fund.stockProject.auth.repository.UserRepository; +import com.fund.stockProject.user.entity.User; +import com.fund.stockProject.user.repository.UserRepository; import com.fund.stockProject.experiment.dto.ExperimentSimpleResponse; import com.fund.stockProject.experiment.dto.ExperimentStatusResponse; import com.fund.stockProject.experiment.dto.ExperimentItemInfoResponse; @@ -44,7 +44,7 @@ public class ExperimentService { public Mono getExperimentStatus( final CustomUserDetails customUserDetails) { // 로그인한 유저 관련 모의 투자 정보 조회 - final List experimentItemsByUserId = experimentRepository.findExperimentItemsByEmail( + final List experimentItemsByUserId = experimentRepository.findExperimentItemsByUser_Email( customUserDetails.getEmail()); if (experimentItemsByUserId.isEmpty()) { diff --git a/src/main/java/com/fund/stockProject/global/config/SecurityConfig.java b/src/main/java/com/fund/stockProject/global/config/SecurityConfig.java index d19d684..c4ad12c 100644 --- a/src/main/java/com/fund/stockProject/global/config/SecurityConfig.java +++ b/src/main/java/com/fund/stockProject/global/config/SecurityConfig.java @@ -80,38 +80,27 @@ public AuthenticationManager authenticationManager(AuthenticationConfiguration a public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .cors(cors -> cors.configurationSource(corsConfig.corsConfigurationSource())); - - // 기본 설정 비활성화 - http - .csrf(AbstractHttpConfigurer::disable); // CSRF 보호 비활성화 (JWT 사용 시 필요 없음) - http - .formLogin(AbstractHttpConfigurer::disable); http + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable); - - // 경로 권한 설정 http .authorizeHttpRequests((auth) -> auth - .requestMatchers(PUBLIC_API_PATHS).permitAll() // 이 경로들은 모두 인증 없이 접근 허용 - .requestMatchers(SWAGGER_API_PATHS).permitAll() // Swagger 관련 경로는 모두 인증 없이 접근 허용 - .anyRequest().authenticated() // 그 외 모든 요청은 인증 필요 + .requestMatchers(PUBLIC_API_PATHS).permitAll() + .requestMatchers(SWAGGER_API_PATHS).permitAll() + .anyRequest().authenticated() ); - - // 세션 설정 + // 부분 stateful: 필요 시 세션 생성 & SecurityContext 자동 저장 http .sessionManagement((session) -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS) - .sessionFixation().none()); // 세션 고정 방지 비활성화, JWT 기반 인증이므로 - - // JWT 검증 필터 등록 - Bean으로 주입받은 인스턴스 사용 + .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) + .sessionFixation().migrateSession()) + .securityContext(sc -> sc.requireExplicitSave(false)); http .addFilterBefore(jwtAuthenticationFilter, LogoutFilter.class); - - // 로그인 예외 시 실행 http.exceptionHandling(exception -> exception .authenticationEntryPoint(customAuthenticationEntryPoint) ); - return http.build(); } } diff --git a/src/main/java/com/fund/stockProject/security/filter/JwtAuthenticationFilter.java b/src/main/java/com/fund/stockProject/security/filter/JwtAuthenticationFilter.java index cf731ce..d776d93 100644 --- a/src/main/java/com/fund/stockProject/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/fund/stockProject/security/filter/JwtAuthenticationFilter.java @@ -17,6 +17,7 @@ import org.springframework.lang.NonNull; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @@ -63,32 +64,28 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull FilterChain filterChain) throws ServletException, IOException { - try { - // 이미 인증된 경우는 그대로 다음 필터로 진행 - var currentAuth = SecurityContextHolder.getContext().getAuthentication(); - if (currentAuth != null && currentAuth.isAuthenticated() && !(currentAuth instanceof AnonymousAuthenticationToken)) { - filterChain.doFilter(request, response); - return; - } - - // 토큰 추출 - String accessToken = resolveToken(request); - - // 토큰이 있으면 검증 및 컨텍스트 설정 - if (accessToken != null) { - processJwtAuthentication(accessToken); + String accessToken = resolveToken(request); + Authentication currentAuth = SecurityContextHolder.getContext().getAuthentication(); + + if (accessToken != null) { + try { + if (!isReusable(currentAuth, accessToken)) { + processJwtAuthentication(accessToken); // JWT 파싱/검증 + } + } catch (ExpiredJwtException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) { + log.warn("JWT 검증 실패: {}", e.getMessage()); + SecurityContextHolder.clearContext(); // 토큰 자체 문제일 때만 초기화 } + } - // 체인은 정확히 한 번만 호출 - filterChain.doFilter(request, response); + // 비즈니스 예외는 여기서 잡지 않고 그대로 상위로 전달 (double dispatch 방지) + filterChain.doFilter(request, response); + } - } catch (Exception e) { - // 예외 상황에서만 컨텍스트 정리 후 스프링에 위임 - log.error("JWT 인증 처리 중 오류", e); - SecurityContextHolder.clearContext(); - throw e; - } - // finally 블록 없음: getAsyncContext() 접근 금지 + 중복 체인 호출 방지 + @Override + protected boolean shouldNotFilterAsyncDispatch() { + // async 재디스패치에도 필터 실행하여 SecurityContext 재구성 + return false; } /** @@ -104,11 +101,13 @@ private void processJwtAuthentication(String accessToken) { String email = jwtUtil.getEmail(accessToken); String role = jwtUtil.getRole(accessToken); - // ROLE_ 접두어 부여 + // ROLE_ 접두어 중복 방지 및 중복 권한 제거 List authorities = Stream.of(role.split(",")) .map(String::trim) .filter(r -> !r.isEmpty()) - .map(r -> new SimpleGrantedAuthority("ROLE_" + r)) + .map(r -> r.startsWith("ROLE_") ? r : "ROLE_" + r) + .distinct() + .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); User user = findUserByEmailOptimized(email); @@ -167,4 +166,16 @@ private User findUserByEmailOptimized(String email) { return null; } } + + private boolean isReusable(Authentication auth, String token) { + if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) return false; + if (!(auth.getPrincipal() instanceof CustomUserDetails principal)) return false; + try { + if (!JWT_CATEGORY_ACCESS.equals(jwtUtil.getCategory(token))) return false; + String emailFromToken = jwtUtil.getEmail(token); + return principal.getEmail().equals(emailFromToken); + } catch (Exception e) { + return false; + } + } } From 4334be46f94ae3b1091a00c3967c3d4bd2102655 Mon Sep 17 00:00:00 2001 From: jerome Date: Wed, 17 Sep 2025 23:01:23 +0900 Subject: [PATCH 10/11] =?UTF-8?q?FEAT(#129):=20=EC=8B=A4=ED=97=98=EC=8B=A4?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20API=20=EA=B0=9C=EB=B0=9C=20(=EB=A6=AC?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8,=20=EC=9E=90=EB=8F=99=EB=A7=A4=EB=A7=A4,=20?= =?UTF-8?q?=EB=AA=A8=EC=9D=98=ED=88=AC=EC=9E=90=ED=98=84=ED=99=A9=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C,=20=EB=AA=A8=EC=9D=98=ED=88=AC=EC=9E=90?= =?UTF-8?q?=EC=A2=85=EB=AA=A9=20=EC=83=81=EC=84=B8=EC=A1=B0=ED=9A=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ExperimentController.java | 35 +- .../experiment/domain/SCORERANGE.java | 33 ++ ...ponse.java => ExperimentInfoResponse.java} | 15 +- .../dto/ExperimentReportResponse.java | 20 + .../dto/ExperimentStatusDetailResponse.java | 31 ++ .../dto/ExperimentStatusResponse.java | 8 +- .../experiment/dto/ReportPatternDto.java | 15 + .../experiment/dto/ReportStatisticDto.java | 12 + .../{ExperimentItem.java => Experiment.java} | 18 +- .../entity/ExperimentTradeItem.java | 44 ++ .../repository/ExperimentRepository.java | 96 +++- .../ExperimentTradeItemRepository.java | 18 + .../experiment/service/ExperimentService.java | 503 +++++++++++++----- .../global/config/SecurityConfig.java | 22 +- .../global/scheduler/AutoSellScheduler.java | 54 ++ .../scheduler/AutoSellingScheduler.java | 38 -- .../stock/dto/response/StockInfoResponse.java | 2 - .../stock/service/SecurityService.java | 72 ++- 18 files changed, 814 insertions(+), 222 deletions(-) create mode 100644 src/main/java/com/fund/stockProject/experiment/domain/SCORERANGE.java rename src/main/java/com/fund/stockProject/experiment/dto/{ExperimentItemInfoResponse.java => ExperimentInfoResponse.java} (57%) create mode 100644 src/main/java/com/fund/stockProject/experiment/dto/ExperimentReportResponse.java create mode 100644 src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusDetailResponse.java create mode 100644 src/main/java/com/fund/stockProject/experiment/dto/ReportPatternDto.java create mode 100644 src/main/java/com/fund/stockProject/experiment/dto/ReportStatisticDto.java rename src/main/java/com/fund/stockProject/experiment/entity/{ExperimentItem.java => Experiment.java} (76%) create mode 100644 src/main/java/com/fund/stockProject/experiment/entity/ExperimentTradeItem.java create mode 100644 src/main/java/com/fund/stockProject/experiment/repository/ExperimentTradeItemRepository.java create mode 100644 src/main/java/com/fund/stockProject/global/scheduler/AutoSellScheduler.java delete mode 100644 src/main/java/com/fund/stockProject/global/scheduler/AutoSellingScheduler.java diff --git a/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java b/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java index ee312fb..b686db9 100644 --- a/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java +++ b/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java @@ -1,14 +1,12 @@ package com.fund.stockProject.experiment.controller; +import com.fund.stockProject.experiment.dto.ExperimentReportResponse; import com.fund.stockProject.experiment.dto.ExperimentSimpleResponse; +import com.fund.stockProject.experiment.dto.ExperimentStatusDetailResponse; import com.fund.stockProject.experiment.dto.ExperimentStatusResponse; import com.fund.stockProject.experiment.service.ExperimentService; import com.fund.stockProject.security.principle.CustomUserDetails; -import com.fund.stockProject.stock.domain.COUNTRY; -import com.fund.stockProject.stock.dto.response.StockInfoResponse; -import com.fund.stockProject.stock.dto.response.StockSearchResponse; import io.swagger.v3.oas.annotations.Operation; -import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -16,7 +14,6 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import reactor.core.publisher.Mono; @@ -25,7 +22,7 @@ @RequestMapping("/experiment") public class ExperimentController { - private ExperimentService experimentService; + private final ExperimentService experimentService; @GetMapping("/status") @Operation(summary = "실험(모의 매수) 현황 API", description = "실험(모의 매수) 현황 조회") @@ -33,23 +30,21 @@ public ResponseEntity> getExperimentStatus(@Authe return ResponseEntity.ok().body(experimentService.getExperimentStatus(customUserDetails)); } - @PostMapping("/{id}/buy/{country}") - @Operation(summary = "실험(모의 매수) 종목 매수 API", description = "실험(모의 매수) 종목 매수") - public ResponseEntity> buyExperimentItem(@AuthenticationPrincipal CustomUserDetails customUserDetails, final @PathVariable("id") Integer stockId, final @PathVariable("country") String country) { - return ResponseEntity.ok().body(experimentService.buyExperimentItem(customUserDetails, stockId, country)); + @GetMapping("/status/{experimentId}/detail") + @Operation(summary = "실험(모의 매수) 현황 상세 보기 API", description = "실험(모의 매수) 현황 상세 보기") + public ResponseEntity> getExperimentStatusDetail(@PathVariable("experimentId") Integer experimentId) { + return ResponseEntity.ok().body(experimentService.getExperimentStatusDetail(experimentId)); } - @GetMapping("/search/{searchKeyword}/{country}") - @Operation(summary = "관심 종목 검색 API", description = "주식 종목 및 인간지표 데이터 검색") - public ResponseEntity> searchStockBySymbolName(final @PathVariable String searchKeyword, final @PathVariable String country) { - return ResponseEntity.ok().body(experimentService.searchStockBySymbolName(searchKeyword, country)); + @PostMapping("/{stockId}/buy/{country}") + @Operation(summary = "실험(모의 매수) 종목 매수 API", description = "실험(모의 매수) 종목 매수") + public ResponseEntity> buyExperiment(@AuthenticationPrincipal CustomUserDetails customUserDetails, final @PathVariable("stockId") Integer stockId, final @PathVariable("country") String country) { + return ResponseEntity.ok().body(experimentService.buyExperiment(customUserDetails, stockId, country)); } - @GetMapping("/autocomplete") - @Operation(summary = "관심 종목 검색어 자동완성 API", description = "관심 종목 검색어 자동완성") - public ResponseEntity> autocompleteKeyword(final @RequestParam String keyword) { - return ResponseEntity.ok().body(experimentService.autoCompleteKeyword(keyword)); + @GetMapping("/report") + @Operation(summary = "실험 결과 API", description = "실험 결과 조회") + public ResponseEntity> getReport(@AuthenticationPrincipal CustomUserDetails customUserDetails) { + return ResponseEntity.ok().body(experimentService.getReport(customUserDetails)); } - - } diff --git a/src/main/java/com/fund/stockProject/experiment/domain/SCORERANGE.java b/src/main/java/com/fund/stockProject/experiment/domain/SCORERANGE.java new file mode 100644 index 0000000..322c723 --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/domain/SCORERANGE.java @@ -0,0 +1,33 @@ +package com.fund.stockProject.experiment.domain; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public enum SCORERANGE { + RANGE_0_59("60점 미만"), + RANGE_60_69("60-69점"), + RANGE_70_79("70-79점"), + RANGE_80_89("80-89점"), + RANGE_90_100("90점 이상"); + + private final String range; + + SCORERANGE(String range) { + this.range = range; + } + + @JsonValue + public String getRange() { + return range; + } + + @JsonCreator + public static SCORERANGE fromRange(String range) { + for (SCORERANGE scorerange : SCORERANGE.values()) { + if (scorerange.range.equals(range)) { + return scorerange; + } + } + throw new IllegalArgumentException("Unknown exchange range: " + range); + } +} diff --git a/src/main/java/com/fund/stockProject/experiment/dto/ExperimentItemInfoResponse.java b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentInfoResponse.java similarity index 57% rename from src/main/java/com/fund/stockProject/experiment/dto/ExperimentItemInfoResponse.java rename to src/main/java/com/fund/stockProject/experiment/dto/ExperimentInfoResponse.java index 6e74a23..bf34cf4 100644 --- a/src/main/java/com/fund/stockProject/experiment/dto/ExperimentItemInfoResponse.java +++ b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentInfoResponse.java @@ -1,14 +1,15 @@ package com.fund.stockProject.experiment.dto; +import com.fund.stockProject.stock.domain.COUNTRY; import java.time.LocalDateTime; import lombok.Builder; import lombok.Getter; @Getter @Builder -public class ExperimentItemInfoResponse { +public class ExperimentInfoResponse { - private Integer id; + private Integer experimentId; private String symbolName; @@ -16,13 +17,9 @@ public class ExperimentItemInfoResponse { private Integer buyPrice; - private Double currentPrice; - - private Integer autoSellIn; - - private Double diffPrice; - private Double roi; - private String tradeStatus; + private String status; + + private COUNTRY country; } diff --git a/src/main/java/com/fund/stockProject/experiment/dto/ExperimentReportResponse.java b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentReportResponse.java new file mode 100644 index 0000000..ea61d0b --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentReportResponse.java @@ -0,0 +1,20 @@ +package com.fund.stockProject.experiment.dto; + +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ExperimentReportResponse { + // 1번 결과 데이터 + private long weeklyExperimentCount; // 이번주 진행한 실험 횟수 + private List reportStatisticDtos; // 점수 구간별 평균 수익률 + + // 2번 결과 데이터 + private long totalUserExperiments; // 유저가 진행한 전체 실험 횟수 + private long successUserExperiments; // 유저 실험 중 수익에 성공한 실험 횟수 + private long sameGradeUserRate; // 동일 등급의 전체 유저 비율 + + private List reportPatternDtos; // 인간지표 점수 별 투자 유형 패턴 데이터 +} diff --git a/src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusDetailResponse.java b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusDetailResponse.java new file mode 100644 index 0000000..a418bce --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusDetailResponse.java @@ -0,0 +1,31 @@ +package com.fund.stockProject.experiment.dto; + +import java.time.LocalDateTime; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ExperimentStatusDetailResponse { + private String symbolName; // 종목명 + private double roi; // 최종 수익률 + private String status; // 거래 상태 + private List tradeInfos; // 거래 내역 + + @Getter + public static class TradeInfo { + private double price; // 가격 + private int score; // 점수 + private LocalDateTime tradeAt; // 거래일 + private double roi; // 수익률 + + @Builder + public TradeInfo(Double price, Integer score, LocalDateTime tradeAt, double roi) { + this.price = price; + this.score = score; + this.tradeAt = tradeAt; + this.roi = roi; + } + } +} diff --git a/src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusResponse.java b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusResponse.java index 06609f8..1712a80 100644 --- a/src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusResponse.java +++ b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusResponse.java @@ -8,15 +8,15 @@ @Builder public class ExperimentStatusResponse { - private List progressExperimentItems; + private List progressExperiments; // 진행중인 실험 데이터 - private List completeExperimentItems; + private List completeExperiments; // 완료된 실험 데이터 private double avgRoi; // 평균수익률 - private int totalPaperTradeCount; // 총 실험 수 + private int totalTradeCount; // 총 실험 수 - private int progressPaperTradeCount; // 진행중인 실험 수 + private int progressTradeCount; // 진행중인 실험 수 private double successRate; // 성공률 } diff --git a/src/main/java/com/fund/stockProject/experiment/dto/ReportPatternDto.java b/src/main/java/com/fund/stockProject/experiment/dto/ReportPatternDto.java new file mode 100644 index 0000000..e0b4fa8 --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/dto/ReportPatternDto.java @@ -0,0 +1,15 @@ +package com.fund.stockProject.experiment.dto; + +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class ReportPatternDto { + private double roi; // 수익률 + private int score; // 인간지표 점수 + private LocalDateTime buyAt; // 매수날짜 +} diff --git a/src/main/java/com/fund/stockProject/experiment/dto/ReportStatisticDto.java b/src/main/java/com/fund/stockProject/experiment/dto/ReportStatisticDto.java new file mode 100644 index 0000000..f18c31a --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/dto/ReportStatisticDto.java @@ -0,0 +1,12 @@ +package com.fund.stockProject.experiment.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ReportStatisticDto { + private String scoreRange; + private double totalAvgRoi; + private double userAvgRoi; +} diff --git a/src/main/java/com/fund/stockProject/experiment/entity/ExperimentItem.java b/src/main/java/com/fund/stockProject/experiment/entity/Experiment.java similarity index 76% rename from src/main/java/com/fund/stockProject/experiment/entity/ExperimentItem.java rename to src/main/java/com/fund/stockProject/experiment/entity/Experiment.java index a9ccd31..76910f1 100644 --- a/src/main/java/com/fund/stockProject/experiment/entity/ExperimentItem.java +++ b/src/main/java/com/fund/stockProject/experiment/entity/Experiment.java @@ -11,7 +11,6 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import java.time.LocalDate; import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -24,17 +23,17 @@ @Builder @AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class ExperimentItem { +public class Experiment { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "stock_id", referencedColumnName = "id", insertable = false, updatable = false) + @JoinColumn(name = "stock_id", nullable = false) private Stock stock; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", referencedColumnName = "id", nullable = false) + @JoinColumn(name = "user_id", nullable = false) private User user; @JsonIgnore @@ -46,18 +45,21 @@ public class ExperimentItem { @Column(nullable = false) private Double buyPrice; - @Column(nullable = false) + @Column private Double sellPrice; @Column(nullable = false) private Double roi; @Column(nullable = false) - private String tradeStatus; + private String status; + + @Column(nullable = false) + private int score; - public void updateAutoSellResult(Double sellPrice, String tradeStatus, LocalDateTime sellAt, Double roi) { + public void updateExperiment(Double sellPrice, String status, LocalDateTime sellAt, Double roi) { this.sellPrice = sellPrice; - this.tradeStatus = tradeStatus; + this.status = status; this.sellAt = sellAt; this.roi = roi; } diff --git a/src/main/java/com/fund/stockProject/experiment/entity/ExperimentTradeItem.java b/src/main/java/com/fund/stockProject/experiment/entity/ExperimentTradeItem.java new file mode 100644 index 0000000..97687ff --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/entity/ExperimentTradeItem.java @@ -0,0 +1,44 @@ +package com.fund.stockProject.experiment.entity; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ExperimentTradeItem { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "experiment_id", nullable = false) + private Experiment experiment; + + @JsonIgnore + private LocalDateTime tradeAt; // 거래일 + + @Column(nullable = false) + private Double price; // 가격 + + @Column(nullable = false) + private Integer score; // 점수 + + @Column(nullable = false) + private Double roi; // 수익률 +} diff --git a/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java b/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java index 23ee7b0..1a083e5 100644 --- a/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java +++ b/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java @@ -1,7 +1,6 @@ package com.fund.stockProject.experiment.repository; -import com.fund.stockProject.experiment.entity.ExperimentItem; -import java.time.LocalDate; +import com.fund.stockProject.experiment.entity.Experiment; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -11,19 +10,90 @@ import org.springframework.stereotype.Repository; @Repository -public interface ExperimentRepository extends JpaRepository { - @Query("SELECT e FROM ExperimentItem e WHERE e.email = :email") - List findExperimentItemsByEmail(@Param("email") String email); +public interface ExperimentRepository extends JpaRepository { + @Query("SELECT e FROM Experiment e JOIN e.user u WHERE u.email = :email ORDER BY e.roi ASC") + List findExperimentsByEmail(@Param("email") String email); // 이메일을 기준으로 해당 유저의 실험정보 조회 - @Query("SELECT COUNT(e) FROM ExperimentItem e WHERE e.tradeStatus = 'PROGRESS'") - int countByTradeStatusProgress(); + @Query("SELECT COUNT(e) FROM Experiment e WHERE e.status = :status") + int countExperimentsByStatus(@Param("status") String status); // 상태(진행/완료) 별 실험 개수 - @Query("SELECT e FROM ExperimentItem e WHERE e.stock.id = :stockId AND e.buyAy = :today") - Optional findExperimentItemByStockIdAndBuyAt(@Param("stockId") Integer stockId, @Param("today") LocalDate today); + @Query("SELECT e FROM Experiment e JOIN e.user u WHERE u.email = :email and e.status = :status ORDER BY e.roi ASC") + List findExperimentsByEmailAndStatus(@Param("email") String email, @Param("status") String status); // 이메일과 완료된 실험을 기준으로 해당 유저의 실험정보 조회 - @Query("SELECT e FROM ExperimentItem e WHERE e.stock.id = :stockId AND e.buyAt BETWEEN :start AND :end") - Optional findExperimentItemByStockIdAndBuyAtBetween(@Param("stockId") Integer stockId, @Param("start") LocalDateTime start, @Param("end") LocalDateTime end); + @Query("SELECT e FROM Experiment e WHERE e.experimentId = :experimentId") + Optional findExperimentByExperimentId(@Param("experimentId") Integer experimentId); // 실험Id 값으로 실험 내용 조회 - @Query("SELECT e FROM experiment_item E WHERE DATE(e.buy_at) = CURDATE() - INTERVAL 5 DAY;") - List findExperimentItemsAfter5BusinessDays(); + @Query("SELECT e FROM Experiment e WHERE e.stock.id = :stockId AND e.buyAt BETWEEN :startOfDay and :endOfDay") + Optional findExperimentByStockIdForToday(@Param("stockId") Integer stockId, @Param("startOfDay") LocalDateTime startOfDay, @Param("endOfDay") LocalDateTime endOfDay); // 금일 진행중인 실험 상세정보 + + @Query("SELECT count(e) FROM Experiment e WHERE e.buyAt BETWEEN :startOfWeek and :endOfWeek") + int countExperimentsForWeek(@Param("stockId") String email, @Param("startOfWeek") LocalDateTime startOfWeek, @Param("endOfWeek") LocalDateTime endOfWeek); + + @Query("SELECT e FROM Experiment e JOIN e.user u WHERE u.email = :email and e.status = :status ORDER BY e.roi ASC") + List findAllExperimentsByEmailAndStatus(@Param("email") String email, @Param("status") String status); // 이메일과 완료된 실험을 기준으로 해당 유저의 실험정보 조회 + + @Query("SELECT e FROM Experiment e WHERE e.buyAt BETWEEN :start AND :end") + List findExperimentsAfterFiveDays(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end); + + @Query("SELECT e FROM Experiment e WHERE e.buyAt > :start AND e.status = :status") + List findProgressExperiments(@Param("start") LocalDateTime start, @Param("status") String status); + + @Query("SELECT AVG(e.roi) FROM Experiment e WHERE e.score BETWEEN :start AND :end AND e.email = :email") + double findUserAvgRoi(@Param("start") int start, @Param("end") int end, @Param("email") String email); + + @Query("SELECT AVG(e.roi) FROM Experiment e WHERE e.score BETWEEN :start AND :end") + double findTotalAvgRoi(@Param("start") int start, @Param("end") int end); + + @Query("SELECT ROUND(IFNULL(profitable.cnt, 0) / total.cnt * 100, 1) AS ratio " + + "FROM ( " + + " SELECT e.email, COUNT(e.email) AS cnt " + + " FROM Experiment e " + + " GROUP BY e.email " + + " ) AS total " + + "LEFT JOIN " + + " ( " + + " SELECT e.email, COUNT(e.email) AS cnt " + + " FROM Experiment e. " + + " WHERE e.roi > 0 " + + " GROUP BY e.email " + + " ) AS profitable " + + "ON total.email = profitable.email " + + "WHERE total.email = :email ") + double findSuccessExperimentRate(@Param("email") String email); + + @Query("SELECT count(*) " + + "FROM " + + "( " + + " SELECT ROUND(IFNULL(profitable.cnt, 0) / total.cnt * 100, 1) AS ratio " + + " FROM " + + " ( SELECT e.email, COUNT(e.email) AS cnt " + + " FROM Experiment e " + + " GROUP BY e.email " + + " ) AS total " + + " LEFT JOIN " + + " ( " + + " SELECT e.email, COUNT(e.email) AS cnt " + + " FROM Experiment e " + + " WHERE e.roi > 0 " + + " GROUP BY e.email " + + " ) AS profitable " + + " ON total.email = profitable.email " + + ") a " + + "WHERE a.ratio BETWEEN :startRange AND :endRange") + int countSameGradeUser(@Param("startRange") int startRange, @Param("endRange") int endRange); + + @Query("SELECT " + + " sub.buy_date, " + + " ROUND(AVG(sub.roi), 1) AS avg_roi, " + + " ROUND(AVG(sub.score), 0) AS avg_score " + + "FROM ( " + + " SELECT " + + " DATE(e.buy_at) AS buy_date, " + + " e.roi, " + + " e.score " + + " FROM Experiment e " + + ") AS sub " + + "GROUP BY sub.buy_date " + + "ORDER BY sub.buy_date ") + List findExperimentGroupByBuyAt(); } diff --git a/src/main/java/com/fund/stockProject/experiment/repository/ExperimentTradeItemRepository.java b/src/main/java/com/fund/stockProject/experiment/repository/ExperimentTradeItemRepository.java new file mode 100644 index 0000000..7f31cce --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/repository/ExperimentTradeItemRepository.java @@ -0,0 +1,18 @@ +package com.fund.stockProject.experiment.repository; + +import com.fund.stockProject.experiment.entity.ExperimentTradeItem; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface ExperimentTradeItemRepository extends JpaRepository { + @Query("SELECT e FROM ExperimentTradeItem e WHERE e.experimentId = :experimentId") + List findExperimentTradeItemsByExperimentId(@Param("experimentId") Integer experimentId); + + @Query("SELECT e FROM ExperimentTradeItem e WHERE e.experimentId = :experimentId AND e.tradeAt BETWEEN :start and :end ORDER BY e.tradeAt") + List findExperimentTradeItemsForToday(@Param("experimentId") Integer experimentId, @Param("start") LocalDateTime start, @Param("end") LocalDateTime end); +} diff --git a/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java b/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java index 178e248..f6c95bc 100644 --- a/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java +++ b/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java @@ -2,29 +2,38 @@ import com.fund.stockProject.auth.entity.User; import com.fund.stockProject.auth.repository.UserRepository; +import com.fund.stockProject.experiment.domain.SCORERANGE; +import com.fund.stockProject.experiment.dto.ExperimentReportResponse; import com.fund.stockProject.experiment.dto.ExperimentSimpleResponse; +import com.fund.stockProject.experiment.dto.ExperimentStatusDetailResponse; +import com.fund.stockProject.experiment.dto.ExperimentStatusDetailResponse.TradeInfo; import com.fund.stockProject.experiment.dto.ExperimentStatusResponse; -import com.fund.stockProject.experiment.dto.ExperimentItemInfoResponse; -import com.fund.stockProject.experiment.entity.ExperimentItem; +import com.fund.stockProject.experiment.dto.ExperimentInfoResponse; +import com.fund.stockProject.experiment.dto.ReportPatternDto; +import com.fund.stockProject.experiment.dto.ReportStatisticDto; +import com.fund.stockProject.experiment.entity.Experiment; +import com.fund.stockProject.experiment.entity.ExperimentTradeItem; import com.fund.stockProject.experiment.repository.ExperimentRepository; +import com.fund.stockProject.experiment.repository.ExperimentTradeItemRepository; +import com.fund.stockProject.score.entity.Score; import com.fund.stockProject.score.repository.ScoreRepository; import com.fund.stockProject.security.principle.CustomUserDetails; import com.fund.stockProject.stock.domain.COUNTRY; import com.fund.stockProject.stock.domain.EXCHANGENUM; import com.fund.stockProject.stock.dto.response.StockInfoResponse; -import com.fund.stockProject.stock.dto.response.StockSearchResponse; import com.fund.stockProject.stock.entity.Stock; import com.fund.stockProject.stock.repository.StockQueryRepository; import com.fund.stockProject.stock.repository.StockRepository; import com.fund.stockProject.stock.service.SecurityService; +import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +import java.time.temporal.TemporalAdjusters; import java.util.ArrayList; import java.util.List; import java.util.NoSuchElementException; import java.util.Optional; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -40,25 +49,28 @@ public class ExperimentService { private final ScoreRepository scoreRepository; private final SecurityService securityService; private final StockQueryRepository stockQueryRepository; + private final ExperimentTradeItemRepository experimentTradeItemRepository; - public Mono getExperimentStatus( - final CustomUserDetails customUserDetails) { + /* + * 실험실 - 매수 현황 + * */ + public Mono getExperimentStatus(final CustomUserDetails customUserDetails) { // 로그인한 유저 관련 모의 투자 정보 조회 - final List experimentItemsByUserId = experimentRepository.findExperimentItemsByEmail( + final List experimentsByUserId = experimentRepository.findExperimentsByEmail( customUserDetails.getEmail()); - if (experimentItemsByUserId.isEmpty()) { + if (experimentsByUserId.isEmpty()) { return Mono.empty(); } // 진행중인 모의 투자 종목 - final List progressExperimentItemsInfo = new ArrayList<>(); + final List progressExperimentsInfo = new ArrayList<>(); // 완료된 모의 투자 종목 - final List completeExperimentItemsInfo = new ArrayList<>(); + final List completeExperimentsInfo = new ArrayList<>(); - for (final ExperimentItem experimentItem : experimentItemsByUserId) { - final Optional bySymbol = stockRepository.findBySymbol( - experimentItem.getStock().getSymbol()); + // 로그인한 유저 관련 모의 투자 정보 조회 진행/완료 리스트에 저장 + for (final Experiment experiment : experimentsByUserId) { + final Optional bySymbol = stockRepository.findBySymbol(experiment.getStock().getSymbol()); if (bySymbol.isEmpty()) { throw new NoSuchElementException("No Stock Found"); @@ -75,83 +87,114 @@ public Mono getExperimentStatus( getCountryFromExchangeNum(stock.getExchangeNum()) ).block(); - if (experimentItem.getTradeStatus().equals("PROGRESS")) { - progressExperimentItemsInfo.add(ExperimentItemInfoResponse - .builder() - .id(experimentItem.getId()) - .roi(experimentItem.getRoi()) - .buyAt(experimentItem.getBuyAt()) + if (experiment.getStatus().equals("PROGRESS")) { + progressExperimentsInfo.add(ExperimentInfoResponse.builder() + .experimentId(experiment.getId()) + .roi(experiment.getRoi()) + .buyAt(experiment.getBuyAt()) .symbolName(stock.getSymbolName()) - .currentPrice(stockInfoKorea.getPrice()) - .diffPrice((stockInfoKorea.getPrice()) - experimentItem.getBuyPrice()) - .tradeStatus(experimentItem.getTradeStatus()) + .status(experiment.getStatus()) + .country(stockInfoKorea.getCountry()) .build()); continue; } - completeExperimentItemsInfo.add(ExperimentItemInfoResponse - .builder() - .id(experimentItem.getId()) - .roi(experimentItem.getRoi()) - .buyAt(experimentItem.getBuyAt()) + completeExperimentsInfo.add(ExperimentInfoResponse.builder() + .experimentId(experiment.getId()) + .roi(experiment.getRoi()) + .buyAt(experiment.getBuyAt()) .symbolName(stock.getSymbolName()) - .currentPrice(stockInfoKorea.getPrice()) - .diffPrice((stockInfoKorea.getPrice()) - experimentItem.getBuyPrice()) - .tradeStatus(experimentItem.getTradeStatus()) + .status(experiment.getStatus()) + .country(stockInfoKorea.getCountry()) .build()); } - final int countByTradeStatusCompleted = experimentRepository.countByTradeStatusProgress(); // 진행중인 실험 수 + final int countByStatusCompleted = experimentRepository.countExperimentsByStatus("PROGRESS"); // 진행중인 실험 수 - final double averageRoi = experimentItemsByUserId - .stream() - .mapToDouble(ExperimentItem::getRoi) // 각 ROI 값을 double로 추출 + final double averageRoi = experimentsByUserId.stream() + .mapToDouble(Experiment::getRoi) // 각 ROI 값을 double로 추출 .average() // OptionalDouble 반환 .orElse(0.0); - final long count = experimentItemsByUserId.stream() - .filter(p -> p.getSellPrice() - p.getBuyPrice() > 0).count(); // 모의투자에 성공한 종목 개수 - double successRate = ((double) count / experimentItemsByUserId.size()) * 100; + final long count = experimentsByUserId.stream().filter(p -> p.getSellPrice() - p.getBuyPrice() > 0).count(); // 모의투자에 성공한 종목 개수 + double successRate = ((double) count / experimentsByUserId.size()) * 100; - final ExperimentStatusResponse experimentStatusResponse = ExperimentStatusResponse. - builder() - .progressExperimentItems(progressExperimentItemsInfo) // 진행중인 실험 정보 - .completeExperimentItems(completeExperimentItemsInfo) // 완료된 실험 정보 + final ExperimentStatusResponse experimentStatusResponse = ExperimentStatusResponse.builder() + .progressExperiments(progressExperimentsInfo) // 진행중인 실험 정보 + .completeExperiments(completeExperimentsInfo) // 완료된 실험 정보 .avgRoi(averageRoi) // 평균 수익률 - .totalPaperTradeCount(experimentItemsByUserId.size()) // 총 실험 수 (전체 모의투자 개수) - .progressPaperTradeCount(countByTradeStatusCompleted) // 진행중인 실험 수 (진행중인 모의투자 개수) + .totalTradeCount(experimentsByUserId.size()) // 총 실험 수 (전체 모의투자 개수) + .progressTradeCount(countByStatusCompleted) // 진행중인 실험 수 (진행중인 모의투자 개수) .successRate(successRate) // 성공률 .build(); return Mono.just(experimentStatusResponse); } + /* + * 실험실 - 종목 매수 현황 자세히 보기 + * */ + public Mono getExperimentStatusDetail(final Integer experimentId) { + // 자세히 보기 선택한 실험 데이터 조회 + final Experiment experiment = experimentRepository.findExperimentByExperimentId(experimentId).get(); + // 실험 데이터에 해당하는 자동 모의 실험 내역 조회 + final List experimentTradeItems = experimentTradeItemRepository.findExperimentTradeItemsByExperimentId(experimentId); + // 가장 최근 수익률 조회 + final ExperimentTradeItem recentExperimentTradeItem = experimentTradeItems.get(experimentTradeItems.size() - 1); + + // 최종 수익률 계산: ((판매가 - 매수가) / 매수가) * 100 + double roi = ((recentExperimentTradeItem.getPrice() - experiment.getBuyPrice()) / experiment.getBuyPrice()) * 100; + + final List tradeInfos = new ArrayList<>(); + + for (final ExperimentTradeItem experimentTradeItem : experimentTradeItems) { + tradeInfos.add(TradeInfo.builder() + .price(experimentTradeItem.getPrice()) + .tradeAt(experimentTradeItem.getTradeAt()) + .score(experimentTradeItem.getScore()) + .roi(experimentTradeItem.getRoi()) + .build() + ); + } + + return Mono.just(ExperimentStatusDetailResponse.builder() + .tradeInfos(tradeInfos) + .roi(roi) + .status(experiment.getStatus()) + .symbolName(experiment.getStock().getSymbolName()) + .build()); + } + private COUNTRY getCountryFromExchangeNum(EXCHANGENUM exchangenum) { - return List.of(EXCHANGENUM.KOSPI, EXCHANGENUM.KOSDAQ, EXCHANGENUM.KOREAN_ETF) - .contains(exchangenum) ? COUNTRY.KOREA : COUNTRY.OVERSEA; + return List.of(EXCHANGENUM.KOSPI, EXCHANGENUM.KOSDAQ, EXCHANGENUM.KOREAN_ETF).contains(exchangenum) ? COUNTRY.KOREA : COUNTRY.OVERSEA; } - public Mono buyExperimentItem(final CustomUserDetails customUserDetails, final Integer stockId, String country) { + public Mono buyExperiment(final CustomUserDetails customUserDetails, final Integer stockId, String country) { final Optional stockById = stockRepository.findStockById(stockId); final Optional userById = userRepository.findByEmail(customUserDetails.getEmail()); - if (stockById.isEmpty() || userById.isEmpty()) { - return Mono.empty(); - } - final Stock stock = stockById.get(); final User user = userById.get(); - LocalDateTime now = LocalDateTime.now(); - LocalTime current = now.toLocalTime(); - LocalDate today = now.toLocalDate(); - LocalTime koreaUpdateTime = LocalTime.of(17, 0); - LocalTime overseasUpdateTime = LocalTime.of(6, 0); + final LocalDateTime now = LocalDateTime.now(); // 현재 날짜와 시간 + final LocalTime current = now.toLocalTime(); // 현재 시간 + final LocalDateTime startOfToday = now.toLocalDate().atStartOfDay(); // 오늘 시작 시간 + final LocalDateTime endOfToday = now.toLocalDate().atTime(LocalTime.MAX); // 오늘 마지막 시간 + final DayOfWeek dayOfWeek = now.getDayOfWeek(); // 요일 Double price = 0.0d; - final Mono securityStockInfoKorea = securityService.getSecurityStockInfoKorea(stock.getId(), stock.getSymbolName(), - stock.getSecurityName(), stock.getSymbol(), stock.getExchangeNum(), getCountryFromExchangeNum(stock.getExchangeNum())); + final Score findByStockIdAndDate = scoreRepository.findByStockIdAndDate(stockId, LocalDate.now()).get(); + int score = 9999; + + final Mono securityStockInfoKorea = securityService.getSecurityStockInfoKorea2( + stock.getId(), + stock.getSymbolName(), + stock.getSecurityName(), + stock.getSymbol(), + stock.getExchangeNum(), + getCountryFromExchangeNum(stock.getExchangeNum()) + ); if (securityStockInfoKorea.blockOptional().isEmpty()) { return Mono.empty(); @@ -160,28 +203,33 @@ public Mono buyExperimentItem(final CustomUserDetails final StockInfoResponse stockInfoResponse = securityStockInfoKorea.block(); if (stockInfoResponse.getCountry().equals(COUNTRY.KOREA)) { - final Optional experimentItemByStockIdAndBuyAt = experimentRepository.findExperimentItemByStockIdAndBuyAt(stockId, today); + score = findByStockIdAndDate.getScoreKorea(); + final Optional experimentByStockIdAndBuyAt = experimentRepository.findExperimentByStockIdForToday(stockId, startOfToday, endOfToday); // 하루에 같은 종목 중복 구매 불가 처리 - if (experimentItemByStockIdAndBuyAt.isPresent()) { - return Mono.just( - ExperimentSimpleResponse.builder() - .message("같은 종목 중복 구매") - .success(false) - .price(0.0d) - .build() + if (experimentByStockIdAndBuyAt.isPresent()) { + return Mono.just(ExperimentSimpleResponse.builder() + .message("같은 종목 중복 구매") + .success(false) + .price(0.0d) + .build() ); } + LocalTime koreaEndTime = LocalTime.of(17, 0); + // 종가 결정 - price = current.isBefore(koreaUpdateTime) ? stockInfoResponse.getYesterdayPrice() : stockInfoResponse.getTodayPrice(); + if (dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY) { + price = stockInfoResponse.getYesterdayPrice(); + } else { + price = current.isBefore(koreaEndTime) ? stockInfoResponse.getYesterdayPrice() : stockInfoResponse.getPrice(); + } } else { // 해외 주식 로직 - LocalDateTime overseasStartTime = now.withHour(6).withMinute(0).withSecond(0).withNano(0); // 오늘 06:00 - LocalDateTime overseasPreviousDayTime = overseasStartTime.minusDays(1).plusMinutes(1); // 전날 06:01 + score = findByStockIdAndDate.getScoreOversea(); // 해당 구간에 이미 매수한 경우 중복 매수 방지 - Optional existingItem = experimentRepository.findExperimentItemByStockIdAndBuyAtBetween(stockId, overseasPreviousDayTime, overseasStartTime); + Optional existingItem = experimentRepository.findExperimentByStockIdForToday(stockId, startOfToday, endOfToday); if (existingItem.isPresent()) { return Mono.just(ExperimentSimpleResponse.builder() @@ -192,53 +240,243 @@ public Mono buyExperimentItem(final CustomUserDetails ); } + LocalTime overseasEndTime = LocalTime.of(6, 0); // 종가 결정 - price = current.isBefore(overseasUpdateTime) ? stockInfoResponse.getYesterdayPrice() : stockInfoResponse.getTodayPrice(); + price = current.isBefore(overseasEndTime) ? stockInfoResponse.getYesterdayPrice() : stockInfoResponse.getPrice(); + + // 종가 결정 + if (dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY) { + price = stockInfoResponse.getYesterdayPrice(); + } else { + price = current.isBefore(overseasEndTime) ? stockInfoResponse.getYesterdayPrice() : stockInfoResponse.getPrice(); + } } - final ExperimentItem experimentItem = ExperimentItem.builder() + List scores = stock.getScores(); + + // 저장할 실험 데이터 생성 + final Experiment experiment = Experiment.builder() .user(user) .stock(stock) - .tradeStatus("PROGRESS") + .status("PROGRESS") .buyAt(now) .buyPrice(price) + .score(score) .build(); - experimentRepository.save(experimentItem); + // 모의 매수한 실험 데이터 저장 + experimentRepository.save(experiment); + + final ExperimentTradeItem experimentTradeItem = ExperimentTradeItem.builder() + .experiment(experiment) + .price(price) + .roi(0.0d) + .score(experiment.getScore()) + .tradeAt(now) + .build(); + + experimentTradeItemRepository.save(experimentTradeItem); return Mono.just(ExperimentSimpleResponse.builder() - .message("모의 매수 성공") - .success(true) - .price(price) - .build() + .message("모의 매수 성공") + .success(true) + .price(price) + .build() ); } + // 매수결과 조회 + public Mono getReport(CustomUserDetails customUserDetails) { + final String email = customUserDetails.getEmail(); + + // 인간지표 점수대별 평균 수익률 + final List reportStatisticDtos = new ArrayList<>(); + + // 1. 60점 이하 평균 수익률 + final double totalAvgRoi_0_59 = experimentRepository.findTotalAvgRoi(0, 59); + final double userAvgRoi_0_59 = experimentRepository.findUserAvgRoi(0, 59, email); + + reportStatisticDtos.add(ReportStatisticDto.builder() + .totalAvgRoi(totalAvgRoi_0_59) + .userAvgRoi(userAvgRoi_0_59) + .scoreRange(SCORERANGE.RANGE_0_59.getRange()) + .build()); + + // 2. 60~69점 평균 수익률 + final double totalAvgRoi_60_69 = experimentRepository.findTotalAvgRoi(60, 69); + final double userAvgRoi_60_69 = experimentRepository.findUserAvgRoi(60, 69, email); + + reportStatisticDtos.add(ReportStatisticDto.builder() + .totalAvgRoi(totalAvgRoi_60_69) + .userAvgRoi(userAvgRoi_60_69) + .scoreRange(SCORERANGE.RANGE_60_69.getRange()) + .build()); + + // 3. 70~79점 평균 수익률 + final double totalAvgRoi_70_79 = experimentRepository.findTotalAvgRoi(70, 79); + final double userAvgRoi_70_79 = experimentRepository.findUserAvgRoi(70, 79, email); + + reportStatisticDtos.add(ReportStatisticDto.builder() + .totalAvgRoi(totalAvgRoi_70_79) + .userAvgRoi(userAvgRoi_70_79) + .scoreRange(SCORERANGE.RANGE_70_79.getRange()) + .build()); + + // 3. 80~89점 평균 수익률 + final double totalAvgRoi_80_89 = experimentRepository.findTotalAvgRoi(80, 89); + final double userAvgRoi_80_89 = experimentRepository.findUserAvgRoi(80, 89, email); + + reportStatisticDtos.add(ReportStatisticDto.builder() + .totalAvgRoi(totalAvgRoi_80_89) + .userAvgRoi(userAvgRoi_80_89) + .scoreRange(SCORERANGE.RANGE_80_89.getRange()) + .build()); + + // 5. 90이상 평균 수익률 + final double totalAvgRoi_90_100 = experimentRepository.findTotalAvgRoi(90, 100); + final double userAvgRoi_90_100 = experimentRepository.findUserAvgRoi(90, 100, email); + + reportStatisticDtos.add(ReportStatisticDto.builder() + .totalAvgRoi(totalAvgRoi_90_100) + .userAvgRoi(userAvgRoi_90_100) + .scoreRange(SCORERANGE.RANGE_90_100.getRange()) + .build()); + + LocalDateTime now = LocalDateTime.now(); + + // 이번 주 월요일 00:00:00 + LocalDateTime startOfWeek = now.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + .toLocalDate() + .atStartOfDay(); + + // 이번 주 금요일 23:59:59.999999999 + LocalDateTime endOfWeek = now.with(TemporalAdjusters.nextOrSame(DayOfWeek.FRIDAY)) + .toLocalDate() + .atTime(LocalTime.MAX); + + // 이번주 진행 실험 횟수 + final int weeklyExperimentCount = experimentRepository.countExperimentsForWeek(email, + startOfWeek, endOfWeek); + + // 특정 사용자가 진행한 총 실험 + final List experimentsByEmailAndStatus = experimentRepository.findExperimentsByEmailAndStatus( + email, "COMPLETED"); + + // 전체 진행한 실험 개수 + long totalExperimentCount = experimentsByEmailAndStatus.size(); + + // 성공한 수익률 데이터 + long successExperimentCount = experimentsByEmailAndStatus.stream() + .filter(experiment -> experiment.getRoi() > 0).count(); + + // 유저 성공률 데이터 조회 + final double successExperimentRate = experimentRepository.findSuccessExperimentRate(email); + int startRange = 0; + int endRange; + + if (0 < successExperimentRate && successExperimentRate <= 20) { + endRange = 20; + } else if (successExperimentRate <= 40) { + startRange = 21; + endRange = 40; + } else if (successExperimentRate <= 60) { + startRange = 41; + endRange = 60; + } else if (successExperimentRate <= 80) { + startRange = 61; + endRange = 80; + } else { + startRange = 81; + endRange = 100; + } + + // 동일 등급 전체 유저 비율 계산 + final int countSameGradeUser = experimentRepository.countSameGradeUser(startRange, endRange); + final long userCount = userRepository.count(); + long sameGradeUserRage = (countSameGradeUser * 100L / userCount); + + final List reportPatternDtos = new ArrayList<>(); + + // 인간지표 점수 별 투자 유형 패턴 데이터 + final List experimentGroupByBuyAt = experimentRepository.findExperimentGroupByBuyAt(); + + for (final Experiment experiment : experimentGroupByBuyAt) { + reportPatternDtos.add( + ReportPatternDto.builder() + .score(experiment.getScore()) + .buyAt(experiment.getBuyAt()) + .roi(experiment.getRoi()) + .build() + ); + } + + // 투자 패턴 그래프 데이터 + final ExperimentReportResponse experimentReportResponse = ExperimentReportResponse.builder() + .weeklyExperimentCount(weeklyExperimentCount) + .reportStatisticDtos(reportStatisticDtos) + .sameGradeUserRate(sameGradeUserRage) + .successUserExperiments(successExperimentCount) + .totalUserExperiments(totalExperimentCount) + .reportPatternDtos(reportPatternDtos) + .build(); + + return Mono.just(experimentReportResponse); + } + + // 영업일 기준 실험 진행한 기간이 5일째인 실험 데이터 조회 @Transactional(readOnly = true) - public List findExperimentItemAfter5BusinessDays() { - final List experimentItemsAfter5BusinessDays = experimentRepository.findExperimentItemsAfter5BusinessDays(); + public List findExperimentsAfter5BusinessDays() { + LocalDate fiveBusinessDaysAgo = calculatePreviousBusinessDate(LocalDate.now()); + LocalDateTime start = fiveBusinessDaysAgo.atStartOfDay(); + LocalDateTime end = fiveBusinessDaysAgo.atTime(LocalTime.MAX); - if (experimentItemsAfter5BusinessDays.isEmpty()) { + final List ExperimentsAfter5BusinessDays = experimentRepository.findExperimentsAfterFiveDays(start, end); + + if (ExperimentsAfter5BusinessDays.isEmpty()) { return new ArrayList<>(); } - return experimentItemsAfter5BusinessDays; + return ExperimentsAfter5BusinessDays; } - @Transactional - public void updateAutoSellStockStatus(ExperimentItem experimentItem) { - try { - final Stock stock = experimentItem.getStock(); + // 5영업일 전 날짜 찾는 함수 + private LocalDate calculatePreviousBusinessDate(LocalDate fromDate) { + int daysCounted = 0; + LocalDate date = fromDate; - final Mono securityStockInfoKorea = securityService.getSecurityStockInfoKorea( - stock.getId(), stock.getSymbolName(), stock.getSecurityName(), stock.getSymbol(), - stock.getExchangeNum(), getCountryFromExchangeNum(stock.getExchangeNum())); + while (daysCounted < 5) { + date = date.minusDays(1); + DayOfWeek day = date.getDayOfWeek(); + + // 주말 제외 + if (day != DayOfWeek.SATURDAY && day != DayOfWeek.SUNDAY) { + daysCounted++; + } + } + + return date; + } + + // 자동판매 - 실험 데이터 수정 + public void updateExperiment(Experiment experiment) { + try { + final Stock stock = experiment.getStock(); + + final Mono securityStockInfoKorea = securityService.getSecurityStockInfoKorea + ( + stock.getId(), + stock.getSymbolName(), + stock.getSecurityName(), + stock.getSymbol(), + stock.getExchangeNum(), + getCountryFromExchangeNum(stock.getExchangeNum()) + ); if (securityStockInfoKorea.blockOptional().isPresent()) { - final Double price = securityStockInfoKorea.block().getTodayPrice(); - Double roi = - ((experimentItem.getBuyPrice() - price) % experimentItem.getBuyPrice()) * 100; - experimentItem.updateAutoSellResult(price, "COMPLETE", LocalDateTime.now(), roi); + final Double price = securityStockInfoKorea.block().getPrice(); + final Double roi = ((experiment.getBuyPrice() - price) % experiment.getBuyPrice()) * 100; + + experiment.updateExperiment(price, "COMPLETE", LocalDateTime.now(), roi); } } catch (Exception e) { @@ -246,40 +484,67 @@ public void updateAutoSellStockStatus(ExperimentItem experimentItem) { } } - public Mono searchStockBySymbolName(final String searchKeyword, final String country) { - List koreaExchanges = List.of(EXCHANGENUM.KOSPI, EXCHANGENUM.KOSDAQ, EXCHANGENUM.KOREAN_ETF); - List overseaExchanges = List.of(EXCHANGENUM.NAS, EXCHANGENUM.NYS, EXCHANGENUM.AMS); + // 실험 진행이 5영업일이 지나지 않은 실험 데이터 조회 + public List findExperimentsPrevious5BusinessDays() { + final LocalDate now = LocalDate.now(); + LocalDate fiveBusinessDaysAgo = calculatePreviousBusinessDate(now); + LocalDateTime start = fiveBusinessDaysAgo.atTime(LocalTime.MAX); - final Optional bySymbolNameAndCountryWithEnums = stockRepository.findBySearchKeywordAndCountryWithEnums( - searchKeyword, country, koreaExchanges, overseaExchanges); + final List ExperimentsAfter5BusinessDays = experimentRepository.findProgressExperiments(start, "PROGRESS"); - if (bySymbolNameAndCountryWithEnums.isPresent()) { - final Stock stock = bySymbolNameAndCountryWithEnums.get(); - - return securityService.getSecurityStockInfoKorea(stock.getId(), stock.getSymbolName(), - stock.getSecurityName(), stock.getSymbol(), stock.getExchangeNum(), - getCountryFromExchangeNum(stock.getExchangeNum())); + if (ExperimentsAfter5BusinessDays.isEmpty()) { + return new ArrayList<>(); } - return Mono.empty(); + return ExperimentsAfter5BusinessDays; } - public List autoCompleteKeyword(String keyword) { - final List stocks = stockQueryRepository.autocompleteKeyword(keyword); + public void saveExperiment(Experiment experiment) { - if (stocks.isEmpty()) { - return null; - } + } - return stocks.stream() - .map(stock -> StockSearchResponse.builder() - .stockId(stock.getId()) - .symbol(stock.getSymbol()) - .symbolName(stock.getSymbolName()) - .securityName(stock.getSecurityName()) - .exchangeNum(stock.getExchangeNum()) - .country(getCountryFromExchangeNum(stock.getExchangeNum())) - .build()) - .collect(Collectors.toList()); + public void saveExperimentTradeItem(Experiment experiment) { + final LocalDateTime now = LocalDateTime.now(); + final LocalDateTime startOfToday = now.toLocalDate().atStartOfDay(); // 오늘 시작 시간 + final LocalDateTime endOfToday = now.toLocalDate().atTime(LocalTime.MAX); // 오늘 마지막 시간 + + final List experimentTradeItemsByExperimentId = experimentTradeItemRepository.findExperimentTradeItemsForToday(experiment.getId(), startOfToday, endOfToday); + + if (experimentTradeItemsByExperimentId.isEmpty()) { + final Stock stock = experiment.getStock(); + + final Mono securityStockInfoKorea = securityService.getSecurityStockInfoKorea + ( + stock.getId(), + stock.getSymbolName(), + stock.getSecurityName(), + stock.getSymbol(), + stock.getExchangeNum(), + getCountryFromExchangeNum(stock.getExchangeNum()) + ); + + final StockInfoResponse stockInfoResponse = securityStockInfoKorea.block(); + + final Double price = stockInfoResponse.getPrice(); + double roi = ((price - experiment.getBuyPrice()) / experiment.getBuyPrice()) * 100; + final Score findByStockIdAndDate = scoreRepository.findByStockIdAndDate(experiment.getStock().getId(), LocalDate.now()).get(); + int score = 9999; + + if (stockInfoResponse.getCountry().equals(COUNTRY.KOREA)) { + score = findByStockIdAndDate.getScoreKorea(); + } else { + score = findByStockIdAndDate.getScoreOversea(); + } + + final ExperimentTradeItem experimentTradeItem = ExperimentTradeItem.builder() + .tradeAt(now) + .score(score) + .roi(roi) + .price(price) + .experiment(experiment) + .build(); + + experimentTradeItemRepository.save(experimentTradeItem); + } } } diff --git a/src/main/java/com/fund/stockProject/global/config/SecurityConfig.java b/src/main/java/com/fund/stockProject/global/config/SecurityConfig.java index 752f241..e7a361a 100644 --- a/src/main/java/com/fund/stockProject/global/config/SecurityConfig.java +++ b/src/main/java/com/fund/stockProject/global/config/SecurityConfig.java @@ -4,14 +4,19 @@ import com.fund.stockProject.security.filter.JwtAuthenticationFilter; import com.fund.stockProject.security.util.JwtUtil; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.InitializingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.AsyncTaskExecutor; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.task.DelegatingSecurityContextAsyncTaskExecutor; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.logout.LogoutFilter; @@ -65,9 +70,24 @@ public class SecurityConfig { "/stock/{id}/info/{country}", "/stock/category/{category}/{country}", "/stock/rankings/hot", - "/stock/summary/{symbol}/{country}" + "/stock/summary/{symbol}/{country}", + "/experiment/status", + "/experiment/{id}/buy/{country}", + "/experiment/status/{id}/detail", + "/experiment/report" }; + @Bean + public AsyncTaskExecutor taskExecutor() { + ThreadPoolTaskExecutor delegate = new ThreadPoolTaskExecutor(); + delegate.setCorePoolSize(4); + delegate.setMaxPoolSize(8); + delegate.setQueueCapacity(100); + delegate.initialize(); + // 작업 실행 시 SecurityContext를 복사/복원 + return new DelegatingSecurityContextAsyncTaskExecutor(delegate); + } + @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); diff --git a/src/main/java/com/fund/stockProject/global/scheduler/AutoSellScheduler.java b/src/main/java/com/fund/stockProject/global/scheduler/AutoSellScheduler.java new file mode 100644 index 0000000..1577751 --- /dev/null +++ b/src/main/java/com/fund/stockProject/global/scheduler/AutoSellScheduler.java @@ -0,0 +1,54 @@ +package com.fund.stockProject.global.scheduler; + +import com.fund.stockProject.experiment.entity.Experiment; +import com.fund.stockProject.experiment.service.ExperimentService; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AutoSellScheduler { + + private ExperimentService experimentService; + + /** + * 모의투자 자동매매 스케줄러 + */ + @Scheduled(cron = "0 30 23 ? * MON-FRI", zone = "Asia/Seoul") + public void processAutoSell() { + LocalDate today = LocalDate.now(); + + // 모의 매수 5일차 종목 조회 + final List experimentsAfter5BusinessDays = experimentService.findExperimentsAfter5BusinessDays(); + + for (Experiment experiment : experimentsAfter5BusinessDays) { + try { + experimentService.updateExperiment(experiment); + } catch (Exception e) { + System.err.println("Error processing autoSell " + experiment.getId() + " - " + e.getMessage()); + } + } + + } + + @Scheduled(cron = "0 0 18 * * ?", zone = "Asia/Seoul") + public void processProgressExperiment() { + LocalDate today = LocalDate.now(); + + // 모의 매수 5일차 미만의 실험 진행중인 종목 조회 + final List experimentsPrevious5BusinessDays = experimentService.findExperimentsPrevious5BusinessDays(); + + // 영업일 기준 데이터 저장 + for (Experiment experiment : experimentsPrevious5BusinessDays) { + try { + experimentService.saveExperimentTradeItem(experiment); + } catch (Exception e) { + System.err.println("Error processing Progress Experiment " + experiment.getId() + " - " + e.getMessage()); + } + } + + } +} diff --git a/src/main/java/com/fund/stockProject/global/scheduler/AutoSellingScheduler.java b/src/main/java/com/fund/stockProject/global/scheduler/AutoSellingScheduler.java deleted file mode 100644 index 709d4c9..0000000 --- a/src/main/java/com/fund/stockProject/global/scheduler/AutoSellingScheduler.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.fund.stockProject.global.scheduler; - -import com.fund.stockProject.experiment.entity.ExperimentItem; -import com.fund.stockProject.experiment.service.ExperimentService; -import com.fund.stockProject.score.entity.Score; -import com.fund.stockProject.stock.domain.COUNTRY; -import java.time.LocalDate; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class AutoSellingScheduler { - - private ExperimentService experimentService; - - /** - * 모의투자 자동매매 스케줄러 - */ - @Scheduled(cron = "0 30 23 * * ?", zone = "Asia/Seoul") // 6시에 실행 - public void processAutoSell() { - LocalDate today = LocalDate.now(); - - // 모의 매수 5일차 종목 조회 - final List experimentItemsAfter5BusinessDays = experimentService.findExperimentItemAfter5BusinessDays(); - - for (ExperimentItem experimentItem : experimentItemsAfter5BusinessDays) { - try { - experimentService.updateAutoSellStockStatus(experimentItem); - } catch (Exception e) { - System.err.println("Error processing autoSell " + experimentItem.getId() + " - " + e.getMessage()); - } - } - - } -} diff --git a/src/main/java/com/fund/stockProject/stock/dto/response/StockInfoResponse.java b/src/main/java/com/fund/stockProject/stock/dto/response/StockInfoResponse.java index 9cb52e8..c2ec212 100644 --- a/src/main/java/com/fund/stockProject/stock/dto/response/StockInfoResponse.java +++ b/src/main/java/com/fund/stockProject/stock/dto/response/StockInfoResponse.java @@ -32,6 +32,4 @@ public class StockInfoResponse { private Double priceDiffPerCent; private Double yesterdayPrice; - - private Double todayPrice; } diff --git a/src/main/java/com/fund/stockProject/stock/service/SecurityService.java b/src/main/java/com/fund/stockProject/stock/service/SecurityService.java index 9dd9248..76765ee 100644 --- a/src/main/java/com/fund/stockProject/stock/service/SecurityService.java +++ b/src/main/java/com/fund/stockProject/stock/service/SecurityService.java @@ -34,6 +34,67 @@ public class SecurityService { private final WebClient webClient; private final ObjectMapper objectMapper; + /** + * 국내, 해외 주식 정보 조회 + */ + public Mono getSecurityStockInfoKorea2(Integer id, String symbolName, String securityName, String symbol, EXCHANGENUM exchangenum, COUNTRY country) { + if (country == COUNTRY.KOREA) { + return webClient.get() + .uri(uriBuilder -> uriBuilder.path("/uapi/domestic-stock/v1/quotations/inquire-price-2") + .queryParam("fid_cond_mrkt_div_code", "J") + .queryParam("fid_input_iscd", symbol) + .build()) + .headers(httpHeaders -> { + HttpHeaders headers = securityHttpConfig.createSecurityHeaders(); // 항상 최신 헤더 가져오기 + headers.set("tr_id", "FHKST01010100"); // 추가 헤더 설정 + httpHeaders.addAll(headers); + }) + .retrieve() + .bodyToMono(String.class) + .flatMap(response -> parseFStockInfoKorea2(response, id, symbolName, securityName, symbol, exchangenum, country)); + } else if (country == COUNTRY.OVERSEA) { + return webClient.get() + .uri(uriBuilder -> uriBuilder.path("/uapi/overseas-price/v1/quotations/price") + .queryParam("AUTH", "") + .queryParam("EXCD", exchangenum.name()) + .queryParam("SYMB", symbol) + .build()) + .headers(httpHeaders -> { + HttpHeaders headers = securityHttpConfig.createSecurityHeaders(); // 항상 최신 헤더 가져오기 + headers.set("tr_id", "HHDFS00000300"); // 추가 헤더 설정 + httpHeaders.addAll(headers); + }) + .retrieve() + .bodyToMono(String.class) + .flatMap(response -> parseFStockInfoOversea(response, id, symbolName, securityName, symbol, exchangenum, country)); + } else { + return Mono.error(new UnsupportedOperationException("COUNTRY 입력 에러")); + } + } + + private Mono parseFStockInfoKorea2(String response, Integer id, String symbolName, String securityName, String symbol, EXCHANGENUM exchangenum, COUNTRY country) { + try { + JsonNode rootNode = objectMapper.readTree(response); + JsonNode outputNode = rootNode.get("output"); + StockInfoResponse stockInfoResponse = new StockInfoResponse(); + + if (outputNode != null) { + stockInfoResponse.setStockId(id); + stockInfoResponse.setSymbolName(symbolName); + stockInfoResponse.setSecurityName(securityName); + stockInfoResponse.setSymbol(symbol); + stockInfoResponse.setExchangeNum(exchangenum); + stockInfoResponse.setCountry(country); + stockInfoResponse.setYesterdayPrice(outputNode.get("stck_prdy_clpr").asDouble()); // 전일 종가 + stockInfoResponse.setPrice(outputNode.get("stck_prpr").asDouble()); // 현재가 + } + + return Mono.just(stockInfoResponse); + } catch (Exception e) { + return Mono.error(new UnsupportedOperationException("국내 종목 정보가 없습니다")); + } + } + /** * 국내, 해외 주식 정보 조회 */ @@ -85,10 +146,6 @@ private Mono parseFStockInfoKorea(String response, Integer id stockInfoResponse.setSymbol(symbol); stockInfoResponse.setExchangeNum(exchangenum); stockInfoResponse.setCountry(country); - stockInfoResponse.setPrice(outputNode.get("stck_prpr").asDouble()); - stockInfoResponse.setPriceDiff(outputNode.get("prdy_vrss").asDouble()); - stockInfoResponse.setYesterdayPrice(outputNode.get("bfdy_clpr").asDouble()); // 전일종가 - stockInfoResponse.setTodayPrice(outputNode.get("thdt_clpr").asDouble()); // 당일종가 } return Mono.just(stockInfoResponse); @@ -110,9 +167,8 @@ private Mono parseFStockInfoOversea(String response, Integer stockInfoResponse.setCountry(country); stockInfoResponse.setSymbol(symbol); stockInfoResponse.setExchangeNum(exchangenum); - stockInfoResponse.setPrice(outputNode.get("last").asDouble()); - stockInfoResponse.setYesterdayPrice(outputNode.get("last").asDouble()); // 전일종가 - stockInfoResponse.setTodayPrice(outputNode.get("base").asDouble()); // 당일종가 + stockInfoResponse.setYesterdayPrice(outputNode.get("base").asDouble()); // 전일종가 + stockInfoResponse.setPrice(outputNode.get("last").asDouble()); // 현재가 // 해외는 diff가 절대값이므로 절대값에 따라 음수로 변경 if(outputNode.get("rate").asDouble() < 0) { stockInfoResponse.setPriceDiff(outputNode.get("diff").asDouble() * -1); @@ -125,7 +181,7 @@ private Mono parseFStockInfoOversea(String response, Integer return Mono.just(stockInfoResponse); } catch (Exception e) { - return Mono.error(new UnsupportedOperationException("국내 종목 정보가 없습니다")); + return Mono.error(new UnsupportedOperationException("해외 종목 정보가 없습니다")); } } From f70bad45028abc50752d48d77ece5d96ef3c9b4b Mon Sep 17 00:00:00 2001 From: MuuiGong Date: Fri, 10 Oct 2025 14:48:12 +0900 Subject: [PATCH 11/11] Fix scheduler configuration: Remove duplicate @EnableScheduling annotation --- .../com/fund/stockProject/global/scheduler/BatchScheduler.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/com/fund/stockProject/global/scheduler/BatchScheduler.java b/src/main/java/com/fund/stockProject/global/scheduler/BatchScheduler.java index 08a7caf..b97321e 100644 --- a/src/main/java/com/fund/stockProject/global/scheduler/BatchScheduler.java +++ b/src/main/java/com/fund/stockProject/global/scheduler/BatchScheduler.java @@ -9,12 +9,10 @@ import org.springframework.batch.core.repository.JobExecutionAlreadyRunningException; import org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException; import org.springframework.batch.core.repository.JobRestartException; -import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @Component -@EnableScheduling @RequiredArgsConstructor public class BatchScheduler {