Skip to content

Commit 1228412

Browse files
authored
Merge pull request #246 from FunD-StockProject/feat/sector-avg-stock-percentage
Feat: 섹터별 평균값 반환, 종목별 퍼센티지 반환 API 구현
2 parents 8aebeb4 + 94cb7e1 commit 1228412

5 files changed

Lines changed: 320 additions & 0 deletions

File tree

src/main/java/com/fund/stockProject/score/repository/ScoreRepository.java

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import org.springframework.transaction.annotation.Transactional;
1414

1515
import com.fund.stockProject.score.entity.Score;
16+
import com.fund.stockProject.stock.domain.DomesticSector;
17+
import com.fund.stockProject.stock.domain.OverseasSector;
1618

1719
@Repository
1820
@EnableJpaRepositories
@@ -102,4 +104,92 @@ public interface ScoreRepository extends JpaRepository<Score, Integer> {
102104
AND s.date = (SELECT MAX(s2.date) FROM Score s2 WHERE s2.stockId = s.stockId)
103105
""")
104106
List<Score> findLatestScoresByStockIds(@Param("stockIds") List<Integer> stockIds);
107+
108+
/**
109+
* 각 stock별 최신 유효 Score 데이터를 조회 (국내 종목용)
110+
* scoreOversea = 9999 이며 scoreKorea != 9999 인 데이터 중 최신
111+
*/
112+
@Query("""
113+
SELECT s
114+
FROM Score s
115+
JOIN FETCH s.stock st
116+
WHERE st.valid = true
117+
AND s.scoreOversea = 9999
118+
AND s.scoreKorea <> 9999
119+
AND s.date = (
120+
SELECT MAX(s2.date)
121+
FROM Score s2
122+
WHERE s2.stockId = s.stockId
123+
AND s2.scoreOversea = 9999
124+
AND s2.scoreKorea <> 9999
125+
)
126+
""")
127+
List<Score> findLatestValidScoresByCountryKorea();
128+
129+
/**
130+
* 각 stock별 최신 유효 Score 데이터를 조회 (해외 종목용)
131+
* scoreKorea = 9999 이며 scoreOversea != 9999 인 데이터 중 최신
132+
*/
133+
@Query("""
134+
SELECT s
135+
FROM Score s
136+
JOIN FETCH s.stock st
137+
WHERE st.valid = true
138+
AND s.scoreKorea = 9999
139+
AND s.scoreOversea <> 9999
140+
AND s.date = (
141+
SELECT MAX(s2.date)
142+
FROM Score s2
143+
WHERE s2.stockId = s.stockId
144+
AND s2.scoreKorea = 9999
145+
AND s2.scoreOversea <> 9999
146+
)
147+
""")
148+
List<Score> findLatestValidScoresByCountryOversea();
149+
150+
/**
151+
* 특정 국내 섹터의 최신 유효 Score 데이터를 조회
152+
*/
153+
@Query("""
154+
SELECT s
155+
FROM Score s
156+
JOIN FETCH s.stock st
157+
WHERE st.valid = true
158+
AND st.domesticSector = :sector
159+
AND s.scoreOversea = 9999
160+
AND s.scoreKorea <> 9999
161+
AND s.date = (
162+
SELECT MAX(s2.date)
163+
FROM Score s2
164+
WHERE s2.stockId = s.stockId
165+
AND s2.scoreOversea = 9999
166+
AND s2.scoreKorea <> 9999
167+
)
168+
""")
169+
List<Score> findLatestValidScoresByDomesticSector(@Param("sector") DomesticSector sector);
170+
171+
/**
172+
* 특정 해외 섹터의 최신 유효 Score 데이터를 조회
173+
*/
174+
@Query("""
175+
SELECT s
176+
FROM Score s
177+
JOIN FETCH s.stock st
178+
WHERE st.valid = true
179+
AND st.overseasSector = :sector
180+
AND s.scoreKorea = 9999
181+
AND s.scoreOversea <> 9999
182+
AND s.date = (
183+
SELECT MAX(s2.date)
184+
FROM Score s2
185+
WHERE s2.stockId = s.stockId
186+
AND s2.scoreKorea = 9999
187+
AND s2.scoreOversea <> 9999
188+
)
189+
""")
190+
List<Score> findLatestValidScoresByOverseasSector(@Param("sector") OverseasSector sector);
191+
192+
Optional<Score> findTopByStockIdAndScoreOverseaAndScoreKoreaNotOrderByDateDesc(Integer stockId, Integer scoreOversea, Integer scoreKorea);
193+
194+
Optional<Score> findTopByStockIdAndScoreKoreaAndScoreOverseaNotOrderByDateDesc(Integer stockId, Integer scoreKorea, Integer scoreOversea);
105195
}

src/main/java/com/fund/stockProject/stock/controller/StockController.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,4 +172,20 @@ public ResponseEntity<PageResponse<ShortViewResponse>> getRecommendationByOverse
172172
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
173173
}
174174
}
175+
176+
@GetMapping("/sector/average/{country}")
177+
@Operation(summary = "섹터별 평균 인간지표 점수", description = "국내/해외 섹터별 최신 인간지표 평균 점수를 반환합니다.")
178+
public ResponseEntity<List<SectorAverageResponse>> getSectorAverageScores(
179+
@PathVariable("country") COUNTRY country
180+
) {
181+
return ResponseEntity.ok(stockService.getSectorAverageScores(country));
182+
}
183+
184+
@GetMapping("/{id}/sector/percentile")
185+
@Operation(summary = "섹터 내 상위 퍼센트", description = "특정 종목이 해당 섹터에서 상위 몇 %인지 반환합니다.")
186+
public ResponseEntity<SectorPercentileResponse> getSectorPercentile(
187+
@PathVariable("id") Integer id
188+
) {
189+
return ResponseEntity.ok(stockService.getSectorPercentile(id));
190+
}
175191
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.fund.stockProject.stock.dto.response;
2+
3+
import lombok.Builder;
4+
import lombok.Getter;
5+
6+
@Getter
7+
public class SectorAverageResponse {
8+
9+
private final String sector;
10+
private final String sectorName;
11+
private final Integer averageScore;
12+
private final Integer count;
13+
14+
@Builder
15+
public SectorAverageResponse(String sector, String sectorName, Integer averageScore, Integer count) {
16+
this.sector = sector;
17+
this.sectorName = sectorName;
18+
this.averageScore = averageScore;
19+
this.count = count;
20+
}
21+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.fund.stockProject.stock.dto.response;
2+
3+
import lombok.Builder;
4+
import lombok.Getter;
5+
6+
@Getter
7+
public class SectorPercentileResponse {
8+
9+
private final Integer stockId;
10+
private final String sector;
11+
private final String sectorName;
12+
private final Integer score;
13+
private final Integer rank;
14+
private final Integer total;
15+
private final Integer topPercent;
16+
17+
@Builder
18+
public SectorPercentileResponse(Integer stockId, String sector, String sectorName, Integer score,
19+
Integer rank, Integer total, Integer topPercent) {
20+
this.stockId = stockId;
21+
this.sector = sector;
22+
this.sectorName = sectorName;
23+
this.score = score;
24+
this.rank = rank;
25+
this.total = total;
26+
this.topPercent = topPercent;
27+
}
28+
}

src/main/java/com/fund/stockProject/stock/service/StockService.java

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import java.time.format.DateTimeFormatter;
3131
import java.util.ArrayList;
3232
import java.util.Comparator;
33+
import java.util.EnumMap;
3334
import java.util.HashMap;
3435
import java.util.List;
3536
import java.util.Map;
@@ -820,6 +821,170 @@ public StockDetailResponse getStockDetailInfo(Integer id, COUNTRY country) {
820821
.build();
821822
}
822823

824+
public List<SectorAverageResponse> getSectorAverageScores(COUNTRY country) {
825+
if (country == COUNTRY.KOREA) {
826+
List<Score> scores = scoreRepository.findLatestValidScoresByCountryKorea();
827+
return buildDomesticSectorAverages(scores);
828+
}
829+
830+
List<Score> scores = scoreRepository.findLatestValidScoresByCountryOversea();
831+
return buildOverseasSectorAverages(scores);
832+
}
833+
834+
public SectorPercentileResponse getSectorPercentile(Integer stockId) {
835+
Stock stock = stockRepository.findStockById(stockId)
836+
.orElseThrow(() -> new RuntimeException("no stock found"));
837+
838+
COUNTRY country = getCountryFromExchangeNum(stock.getExchangeNum());
839+
if (country == COUNTRY.KOREA) {
840+
DomesticSector sector = stock.getDomesticSector();
841+
String sectorName = sector != null ? sector.getName() : DomesticSector.UNKNOWN.getName();
842+
String sectorKey = sector != null ? sector.name() : DomesticSector.UNKNOWN.name();
843+
844+
if (sector == null || sector == DomesticSector.UNKNOWN) {
845+
return buildEmptySectorPercentile(stockId, sectorKey, sectorName, null);
846+
}
847+
848+
Optional<Score> targetScoreOpt = scoreRepository
849+
.findTopByStockIdAndScoreOverseaAndScoreKoreaNotOrderByDateDesc(stockId, 9999, 9999);
850+
if (targetScoreOpt.isEmpty()) {
851+
return buildEmptySectorPercentile(stockId, sectorKey, sectorName, null);
852+
}
853+
854+
int targetScore = targetScoreOpt.get().getScoreKorea();
855+
List<Score> sectorScores = scoreRepository.findLatestValidScoresByDomesticSector(sector);
856+
return buildSectorPercentileResponse(stockId, sectorKey, sectorName, targetScore, sectorScores, true);
857+
}
858+
859+
OverseasSector sector = stock.getOverseasSector();
860+
String sectorName = sector != null ? sector.getName() : OverseasSector.UNKNOWN.getName();
861+
String sectorKey = sector != null ? sector.name() : OverseasSector.UNKNOWN.name();
862+
863+
if (sector == null || sector == OverseasSector.UNKNOWN) {
864+
return buildEmptySectorPercentile(stockId, sectorKey, sectorName, null);
865+
}
866+
867+
Optional<Score> targetScoreOpt = scoreRepository
868+
.findTopByStockIdAndScoreKoreaAndScoreOverseaNotOrderByDateDesc(stockId, 9999, 9999);
869+
if (targetScoreOpt.isEmpty()) {
870+
return buildEmptySectorPercentile(stockId, sectorKey, sectorName, null);
871+
}
872+
873+
int targetScore = targetScoreOpt.get().getScoreOversea();
874+
List<Score> sectorScores = scoreRepository.findLatestValidScoresByOverseasSector(sector);
875+
return buildSectorPercentileResponse(stockId, sectorKey, sectorName, targetScore, sectorScores, false);
876+
}
877+
878+
private List<SectorAverageResponse> buildDomesticSectorAverages(List<Score> scores) {
879+
Map<DomesticSector, long[]> stats = new EnumMap<>(DomesticSector.class);
880+
for (Score score : scores) {
881+
Stock stock = score.getStock();
882+
if (stock == null) {
883+
continue;
884+
}
885+
DomesticSector sector = stock.getDomesticSector();
886+
if (sector == null || sector == DomesticSector.UNKNOWN) {
887+
continue;
888+
}
889+
int value = score.getScoreKorea();
890+
long[] acc = stats.computeIfAbsent(sector, key -> new long[2]);
891+
acc[0] += value;
892+
acc[1] += 1;
893+
}
894+
895+
return stats.entrySet().stream()
896+
.map(entry -> buildSectorAverageResponse(entry.getKey().name(), entry.getKey().getName(), entry.getValue()))
897+
.toList();
898+
}
899+
900+
private List<SectorAverageResponse> buildOverseasSectorAverages(List<Score> scores) {
901+
Map<OverseasSector, long[]> stats = new EnumMap<>(OverseasSector.class);
902+
for (Score score : scores) {
903+
Stock stock = score.getStock();
904+
if (stock == null) {
905+
continue;
906+
}
907+
OverseasSector sector = stock.getOverseasSector();
908+
if (sector == null || sector == OverseasSector.UNKNOWN) {
909+
continue;
910+
}
911+
int value = score.getScoreOversea();
912+
long[] acc = stats.computeIfAbsent(sector, key -> new long[2]);
913+
acc[0] += value;
914+
acc[1] += 1;
915+
}
916+
917+
return stats.entrySet().stream()
918+
.map(entry -> buildSectorAverageResponse(entry.getKey().name(), entry.getKey().getName(), entry.getValue()))
919+
.toList();
920+
}
921+
922+
private SectorAverageResponse buildSectorAverageResponse(String sectorKey, String sectorName, long[] acc) {
923+
long count = acc[1];
924+
int avgScore = count == 0 ? 0 : (int) Math.round(acc[0] / (double) count);
925+
return SectorAverageResponse.builder()
926+
.sector(sectorKey)
927+
.sectorName(sectorName)
928+
.averageScore(avgScore)
929+
.count((int) count)
930+
.build();
931+
}
932+
933+
private SectorPercentileResponse buildSectorPercentileResponse(
934+
Integer stockId,
935+
String sectorKey,
936+
String sectorName,
937+
int targetScore,
938+
List<Score> sectorScores,
939+
boolean isKorea
940+
) {
941+
int total = sectorScores.size();
942+
if (total == 0) {
943+
return buildEmptySectorPercentile(stockId, sectorKey, sectorName, targetScore);
944+
}
945+
946+
long higher = 0;
947+
long equal = 0;
948+
for (Score score : sectorScores) {
949+
int value = isKorea ? score.getScoreKorea() : score.getScoreOversea();
950+
if (value > targetScore) {
951+
higher++;
952+
} else if (value == targetScore) {
953+
equal++;
954+
}
955+
}
956+
957+
int rank = (int) higher + 1;
958+
int topPercent = (int) Math.round((higher + equal) * 100.0 / total);
959+
960+
return SectorPercentileResponse.builder()
961+
.stockId(stockId)
962+
.sector(sectorKey)
963+
.sectorName(sectorName)
964+
.score(targetScore)
965+
.rank(rank)
966+
.total(total)
967+
.topPercent(topPercent)
968+
.build();
969+
}
970+
971+
private SectorPercentileResponse buildEmptySectorPercentile(
972+
Integer stockId,
973+
String sectorKey,
974+
String sectorName,
975+
Integer score
976+
) {
977+
return SectorPercentileResponse.builder()
978+
.stockId(stockId)
979+
.sector(sectorKey)
980+
.sectorName(sectorName)
981+
.score(score)
982+
.rank(0)
983+
.total(0)
984+
.topPercent(0)
985+
.build();
986+
}
987+
823988
private COUNTRY getCountryFromExchangeNum(EXCHANGENUM exchangenum) {
824989
return List.of(EXCHANGENUM.KOSPI, EXCHANGENUM.KOSDAQ, EXCHANGENUM.KOREAN_ETF)
825990
.contains(exchangenum) ? COUNTRY.KOREA : COUNTRY.OVERSEA;

0 commit comments

Comments
 (0)