diff --git a/leopard-core/src/main/java/com/lanyuanxiaoyao/leopard/core/entity/StockScore.java b/leopard-core/src/main/java/com/lanyuanxiaoyao/leopard/core/entity/StockScore.java index 540a47a..164e594 100644 --- a/leopard-core/src/main/java/com/lanyuanxiaoyao/leopard/core/entity/StockScore.java +++ b/leopard-core/src/main/java/com/lanyuanxiaoyao/leopard/core/entity/StockScore.java @@ -2,10 +2,13 @@ package com.lanyuanxiaoyao.leopard.core.entity; import com.lanyuanxiaoyao.leopard.core.Constants; import com.lanyuanxiaoyao.service.template.entity.SimpleEntity; +import jakarta.persistence.ElementCollection; import jakarta.persistence.Entity; import jakarta.persistence.EntityListeners; +import jakarta.persistence.JoinTable; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import java.util.Map; import lombok.Getter; import lombok.Setter; import lombok.ToString; @@ -28,5 +31,8 @@ public class StockScore extends SimpleEntity { private Stock stock; @ManyToOne private StockCollection collection; + @ElementCollection + @JoinTable(name = Constants.DATABASE_PREFIX + "stock_score_extra") + private Map extra; private Double score; } diff --git a/leopard-core/src/main/java/com/lanyuanxiaoyao/leopard/core/service/selector/PyramidStockSelector.java b/leopard-core/src/main/java/com/lanyuanxiaoyao/leopard/core/service/selector/PyramidStockSelector.java index d66b0d5..18f3fde 100644 --- a/leopard-core/src/main/java/com/lanyuanxiaoyao/leopard/core/service/selector/PyramidStockSelector.java +++ b/leopard-core/src/main/java/com/lanyuanxiaoyao/leopard/core/service/selector/PyramidStockSelector.java @@ -4,9 +4,11 @@ import cn.hutool.core.util.ArrayUtil; import cn.hutool.core.util.ObjectUtil; import com.lanyuanxiaoyao.leopard.core.entity.FinanceIndicator; import com.lanyuanxiaoyao.leopard.core.entity.QStock; -import com.lanyuanxiaoyao.leopard.core.entity.Stock; +import com.lanyuanxiaoyao.leopard.core.helper.NumberHelper; import com.lanyuanxiaoyao.leopard.core.repository.StockRepository; import java.time.LocalDate; +import java.util.Comparator; +import java.util.HashMap; import java.util.Set; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; @@ -34,185 +36,202 @@ public class PyramidStockSelector implements StockSelector select(Request request) { // 选择至少有最近5年财报的股票 // 有点奇怪,001400.SZ有近5年的财报但资料显示是2025年才上市的 - var stocks = stockRepository.findAll(QStock.stock.listedDate.before(LocalDate.of(request.year(), 1, 1))); - var scores = stocks.stream().collect(Collectors.toMap(stock -> stock, code -> 0)); - for (Stock stock : stocks) { - var recentIndicators = stock.getIndicators() - .stream() - .filter(indicator -> indicator.getYear() < request.year()) - .sorted((a, b) -> b.getYear() - a.getYear()) - .limit(5) - .toList(); - if (recentIndicators.size() < 5) { - continue; - } - var latestIndicator = recentIndicators.getFirst(); - - var roeScore = 0; - if (recentIndicators.stream().noneMatch(indicator -> indicator.getReturnOnEquity() == null || indicator.getReturnOnEquity() < 0)) { - var averageRoe = recentIndicators.stream() - .map(FinanceIndicator::getReturnOnEquity) - .map(item -> ObjectUtil.defaultIfNull(item, 0.0)) - .mapToDouble(Double::doubleValue) - .average() - .orElse(0.0); - if (averageRoe >= 35) { - roeScore = 550; - } else if (averageRoe >= 30) { - roeScore = 500; - } else if (averageRoe >= 25) { - roeScore = 450; - } else if (averageRoe >= 20) { - roeScore = 400; - } else if (averageRoe >= 15) { - roeScore = 350; - } else if (averageRoe >= 10) { - roeScore = 300; - } - } - scores.put(stock, scores.get(stock) + roeScore); - - var roaScore = 0; - if (recentIndicators.stream().noneMatch(indicator -> indicator.getReturnOnAssets() == null)) { - var averageRoa = recentIndicators.stream() - .map(FinanceIndicator::getReturnOnAssets) - .mapToDouble(Double::doubleValue) - .average() - .orElse(0.0); - if (averageRoa >= 15) { - roaScore = 100; - } else if (averageRoa >= 11) { - roaScore = 80; - } else if (averageRoa >= 7) { - roaScore = 50; - } - } - scores.put(stock, scores.get(stock) + roaScore); - - var netProfitScore = 0; - if (recentIndicators.stream().noneMatch(indicator -> indicator.getNetProfit() == null)) { - var averageNetProfit = recentIndicators.stream() - .map(FinanceIndicator::getNetProfit) - .mapToDouble(Double::doubleValue) - .average() - .orElse(0.0); - if (averageNetProfit >= 10000.0 * 10000000) { - netProfitScore = 150; - } else if (averageNetProfit >= 1000.0 * 10000000) { - netProfitScore = 100; - } - } - scores.put(stock, scores.get(stock) + netProfitScore); - - var cashScore = 0; - if ( - ArrayUtil.isAllNotNull(latestIndicator.getTotalAssetsTurnover(), latestIndicator.getCashAndCashEquivalentsToTotalAssetsRatio()) - && ( - latestIndicator.getTotalAssetsTurnover() > 0.8 && latestIndicator.getCashAndCashEquivalentsToTotalAssetsRatio() >= 0.1 - || latestIndicator.getTotalAssetsTurnover() <= 0.8 && latestIndicator.getCashAndCashEquivalentsToTotalAssetsRatio() >= 0.2 - ) - ) { - cashScore = 50; - } - scores.put(stock, scores.get(stock) + cashScore); - - if (ObjectUtil.isNotNull(latestIndicator.getDaysAccountsReceivableTurnover()) && latestIndicator.getDaysAccountsReceivableTurnover() <= 30) { - scores.put(stock, scores.get(stock) + 20); - } - if (ObjectUtil.isNotNull(latestIndicator.getDaysInventoryTurnover()) && latestIndicator.getDaysInventoryTurnover() <= 30) { - scores.put(stock, scores.get(stock) + 20); - } - if (ArrayUtil.isAllNotNull(latestIndicator.getDaysAccountsReceivableTurnover(), latestIndicator.getDaysInventoryTurnover())) { - if (latestIndicator.getDaysAccountsReceivableTurnover() + latestIndicator.getDaysInventoryTurnover() <= 40) { - scores.put(stock, scores.get(stock) + 20); - } else if (latestIndicator.getDaysAccountsReceivableTurnover() + latestIndicator.getDaysInventoryTurnover() <= 60) { - scores.put(stock, scores.get(stock) + 10); - } - } - - if (recentIndicators.stream().noneMatch(indicator -> indicator.getOperatingGrossProfitMargin() == null)) { - var stat = new DescriptiveStatistics(); - recentIndicators.stream() - .map(FinanceIndicator::getOperatingGrossProfitMargin) - .mapToDouble(Double::doubleValue) - .forEach(stat::addValue); - if (stat.getStandardDeviation() <= 0.3) { - scores.put(stock, scores.get(stock) + 50); - } - } - - var operatingSafeMarginScore = 0; - if (ObjectUtil.isNotNull(latestIndicator.getOperatingSafetyMarginRatio())) { - if (latestIndicator.getOperatingSafetyMarginRatio() >= 70) { - operatingSafeMarginScore = 50; - } else if (latestIndicator.getOperatingSafetyMarginRatio() >= 50) { - operatingSafeMarginScore = 30; - } else if (latestIndicator.getOperatingSafetyMarginRatio() >= 30) { - operatingSafeMarginScore = 10; - } - } - scores.put(stock, scores.get(stock) + operatingSafeMarginScore); - - var netProfitAscendingScore = 0; - if (recentIndicators.stream().noneMatch(indicator -> indicator.getNetProfit() == null)) { - if (recentIndicators.get(0).getNetProfit() > recentIndicators.get(1).getNetProfit()) { - netProfitAscendingScore += 30; - } else { - netProfitAscendingScore -= 30; - } - if (recentIndicators.get(1).getNetProfit() > recentIndicators.get(2).getNetProfit()) { - netProfitAscendingScore += 25; - } else { - netProfitAscendingScore -= 25; - } - - if (recentIndicators.get(2).getNetProfit() > recentIndicators.get(3).getNetProfit()) { - netProfitAscendingScore += 20; - } else { - netProfitAscendingScore -= 20; - } - - if (recentIndicators.get(3).getNetProfit() > recentIndicators.get(4).getNetProfit()) { - netProfitAscendingScore += 15; - } else { - netProfitAscendingScore -= 15; - } - } - scores.put(stock, scores.get(stock) + netProfitAscendingScore); - - var cashAscendingScore = 0; - if (recentIndicators.stream().noneMatch(indicator -> indicator.getCashAndCashEquivalents() == null)) { - if (recentIndicators.get(0).getCashAndCashEquivalents() > recentIndicators.get(1).getCashAndCashEquivalents()) { - cashAscendingScore += 30; - } else { - cashAscendingScore -= 30; - } - - if (recentIndicators.get(1).getCashAndCashEquivalents() > recentIndicators.get(2).getCashAndCashEquivalents()) { - cashAscendingScore += 25; - } else { - cashAscendingScore -= 25; - } - - if (recentIndicators.get(2).getCashAndCashEquivalents() > recentIndicators.get(3).getCashAndCashEquivalents()) { - cashAscendingScore += 20; - } else { - cashAscendingScore -= 20; - } - - if (recentIndicators.get(3).getCashAndCashEquivalents() > recentIndicators.get(4).getCashAndCashEquivalents()) { - cashAscendingScore += 15; - } else { - cashAscendingScore -= 15; - } - } - scores.put(stock, scores.get(stock) + cashAscendingScore); - } - return scores.entrySet() + return stockRepository.findAll(QStock.stock.listedDate.before(LocalDate.of(request.year(), 1, 1))) .stream() - .sorted((e1, e2) -> e2.getValue() - e1.getValue()) - .limit(request.limit()) - .map(entry -> new Candidate(entry.getKey(), entry.getValue())) + .map(stock -> { + var extra = new HashMap(); + var score = 0; + + var recentIndicators = stock.getIndicators() + .stream() + .filter(indicator -> indicator.getYear() < request.year()) + .sorted((a, b) -> b.getYear() - a.getYear()) + .limit(5) + .toList(); + if (recentIndicators.size() < 5) { + return null; + } + var latestIndicator = recentIndicators.getFirst(); + + var roeScore = 0; + if (recentIndicators.stream().noneMatch(indicator -> indicator.getReturnOnEquity() == null || indicator.getReturnOnEquity() < 0)) { + var averageRoe = recentIndicators.stream() + .map(FinanceIndicator::getReturnOnEquity) + .map(item -> ObjectUtil.defaultIfNull(item, 0.0)) + .mapToDouble(Double::doubleValue) + .average() + .orElse(0.0); + if (averageRoe >= 35) { + roeScore = 550; + } else if (averageRoe >= 30) { + roeScore = 500; + } else if (averageRoe >= 25) { + roeScore = 450; + } else if (averageRoe >= 20) { + roeScore = 400; + } else if (averageRoe >= 15) { + roeScore = 350; + } else if (averageRoe >= 10) { + roeScore = 300; + } + extra.put("平均ROE", NumberHelper.formatPriceDouble(averageRoe)); + } + extra.put("平均ROE得分", NumberHelper.formatPriceDouble(roeScore)); + score += roeScore; + + var roaScore = 0; + if (recentIndicators.stream().noneMatch(indicator -> indicator.getReturnOnAssets() == null)) { + var averageRoa = recentIndicators.stream() + .map(FinanceIndicator::getReturnOnAssets) + .mapToDouble(Double::doubleValue) + .average() + .orElse(0.0); + if (averageRoa >= 15) { + roaScore = 100; + } else if (averageRoa >= 11) { + roaScore = 80; + } else if (averageRoa >= 7) { + roaScore = 50; + } + extra.put("平均ROA", NumberHelper.formatPriceDouble(averageRoa)); + } + extra.put("平均ROA得分", NumberHelper.formatPriceDouble(roaScore)); + score += roaScore; + + var netProfitScore = 0; + if (recentIndicators.stream().noneMatch(indicator -> indicator.getNetProfit() == null)) { + var averageNetProfit = recentIndicators.stream() + .map(FinanceIndicator::getNetProfit) + .mapToDouble(Double::doubleValue) + .average() + .orElse(0.0); + if (averageNetProfit >= 10000.0 * 10000000) { + netProfitScore = 150; + } else if (averageNetProfit >= 1000.0 * 10000000) { + netProfitScore = 100; + } + extra.put("平均净利润", NumberHelper.formatPriceDouble(averageNetProfit)); + } + extra.put("平均净利润得分", NumberHelper.formatPriceDouble(netProfitScore)); + score += netProfitScore; + + var cashScore = 0; + if ( + ArrayUtil.isAllNotNull(latestIndicator.getTotalAssetsTurnover(), latestIndicator.getCashAndCashEquivalentsToTotalAssetsRatio()) + && ( + latestIndicator.getTotalAssetsTurnover() > 0.8 && latestIndicator.getCashAndCashEquivalentsToTotalAssetsRatio() >= 0.1 + || latestIndicator.getTotalAssetsTurnover() <= 0.8 && latestIndicator.getCashAndCashEquivalentsToTotalAssetsRatio() >= 0.2 + ) + ) { + cashScore = 50; + } + extra.put("现金流得分", NumberHelper.formatPriceDouble(cashScore)); + score += cashScore; + + if (ObjectUtil.isNotNull(latestIndicator.getDaysAccountsReceivableTurnover()) && latestIndicator.getDaysAccountsReceivableTurnover() <= 30) { + extra.put("应收账款周转天数得分", "20"); + score += 20; + } + if (ObjectUtil.isNotNull(latestIndicator.getDaysInventoryTurnover()) && latestIndicator.getDaysInventoryTurnover() <= 30) { + extra.put("存货周转天数得分", "20"); + score += 20; + } + if (ArrayUtil.isAllNotNull(latestIndicator.getDaysAccountsReceivableTurnover(), latestIndicator.getDaysInventoryTurnover())) { + if (latestIndicator.getDaysAccountsReceivableTurnover() + latestIndicator.getDaysInventoryTurnover() <= 40) { + score += 20; + } else if (latestIndicator.getDaysAccountsReceivableTurnover() + latestIndicator.getDaysInventoryTurnover() <= 60) { + score += 10; + } + } + + if (recentIndicators.stream().noneMatch(indicator -> indicator.getOperatingGrossProfitMargin() == null)) { + var stat = new DescriptiveStatistics(); + recentIndicators.stream() + .map(FinanceIndicator::getOperatingGrossProfitMargin) + .mapToDouble(Double::doubleValue) + .forEach(stat::addValue); + if (stat.getStandardDeviation() <= 0.3) { + extra.put("毛利率标准差得分", "50"); + score += 50; + } + } + + var operatingSafeMarginScore = 0; + if (ObjectUtil.isNotNull(latestIndicator.getOperatingSafetyMarginRatio())) { + if (latestIndicator.getOperatingSafetyMarginRatio() >= 70) { + operatingSafeMarginScore = 50; + } else if (latestIndicator.getOperatingSafetyMarginRatio() >= 50) { + operatingSafeMarginScore = 30; + } else if (latestIndicator.getOperatingSafetyMarginRatio() >= 30) { + operatingSafeMarginScore = 10; + } + extra.put("安全边际比率", NumberHelper.formatPriceDouble(latestIndicator.getOperatingSafetyMarginRatio())); + } + extra.put("安全边际比率得分", NumberHelper.formatPriceDouble(operatingSafeMarginScore)); + score += operatingSafeMarginScore; + + var netProfitAscendingScore = 0; + if (recentIndicators.stream().noneMatch(indicator -> indicator.getNetProfit() == null)) { + if (recentIndicators.get(0).getNetProfit() > recentIndicators.get(1).getNetProfit()) { + netProfitAscendingScore += 30; + } else { + netProfitAscendingScore -= 30; + } + if (recentIndicators.get(1).getNetProfit() > recentIndicators.get(2).getNetProfit()) { + netProfitAscendingScore += 25; + } else { + netProfitAscendingScore -= 25; + } + + if (recentIndicators.get(2).getNetProfit() > recentIndicators.get(3).getNetProfit()) { + netProfitAscendingScore += 20; + } else { + netProfitAscendingScore -= 20; + } + + if (recentIndicators.get(3).getNetProfit() > recentIndicators.get(4).getNetProfit()) { + netProfitAscendingScore += 15; + } else { + netProfitAscendingScore -= 15; + } + } + extra.put("近五年净利润得分", NumberHelper.formatPriceDouble(netProfitAscendingScore)); + score += netProfitAscendingScore; + + var cashAscendingScore = 0; + if (recentIndicators.stream().noneMatch(indicator -> indicator.getCashAndCashEquivalents() == null)) { + if (recentIndicators.get(0).getCashAndCashEquivalents() > recentIndicators.get(1).getCashAndCashEquivalents()) { + cashAscendingScore += 30; + } else { + cashAscendingScore -= 30; + } + + if (recentIndicators.get(1).getCashAndCashEquivalents() > recentIndicators.get(2).getCashAndCashEquivalents()) { + cashAscendingScore += 25; + } else { + cashAscendingScore -= 25; + } + + if (recentIndicators.get(2).getCashAndCashEquivalents() > recentIndicators.get(3).getCashAndCashEquivalents()) { + cashAscendingScore += 20; + } else { + cashAscendingScore -= 20; + } + + if (recentIndicators.get(3).getCashAndCashEquivalents() > recentIndicators.get(4).getCashAndCashEquivalents()) { + cashAscendingScore += 15; + } else { + cashAscendingScore -= 15; + } + } + extra.put("近五年现金流得分", NumberHelper.formatPriceDouble(cashAscendingScore)); + score += cashAscendingScore; + + return new Candidate(stock, score, extra); + }) + .filter(ObjectUtil::isNotNull) + .sorted(Comparator.comparingDouble(Candidate::score).reversed()) + .limit(request.limit) .collect(Collectors.toSet()); } diff --git a/leopard-core/src/main/java/com/lanyuanxiaoyao/leopard/core/service/selector/StockSelector.java b/leopard-core/src/main/java/com/lanyuanxiaoyao/leopard/core/service/selector/StockSelector.java index a5e200c..aacf209 100644 --- a/leopard-core/src/main/java/com/lanyuanxiaoyao/leopard/core/service/selector/StockSelector.java +++ b/leopard-core/src/main/java/com/lanyuanxiaoyao/leopard/core/service/selector/StockSelector.java @@ -13,7 +13,7 @@ import java.util.Set; public interface StockSelector { Set select(T request); - record Candidate(Stock stock, double score, Map extra) { + record Candidate(Stock stock, double score, Map extra) { public Candidate(Stock stock, double score) { this(stock, score, Map.of()); } diff --git a/leopard-core/src/main/java/com/lanyuanxiaoyao/leopard/core/task/PyramidSelect.java b/leopard-core/src/main/java/com/lanyuanxiaoyao/leopard/core/task/PyramidSelect.java index 08502f3..aa698be 100644 --- a/leopard-core/src/main/java/com/lanyuanxiaoyao/leopard/core/task/PyramidSelect.java +++ b/leopard-core/src/main/java/com/lanyuanxiaoyao/leopard/core/task/PyramidSelect.java @@ -45,6 +45,7 @@ public class PyramidSelect extends TaskRunner { var score = new StockScore(); score.setStock(candidate.stock()); score.setScore(candidate.score()); + score.setExtra(candidate.extra()); score.setCollection(collection); return score; }) diff --git a/leopard-server/src/main/java/com/lanyuanxiaoyao/leopard/server/entity/StockScoreVo.java b/leopard-server/src/main/java/com/lanyuanxiaoyao/leopard/server/entity/StockScoreVo.java index d6efd8d..75242bb 100644 --- a/leopard-server/src/main/java/com/lanyuanxiaoyao/leopard/server/entity/StockScoreVo.java +++ b/leopard-server/src/main/java/com/lanyuanxiaoyao/leopard/server/entity/StockScoreVo.java @@ -3,6 +3,8 @@ package com.lanyuanxiaoyao.leopard.server.entity; import com.lanyuanxiaoyao.leopard.core.entity.Stock; import com.lanyuanxiaoyao.leopard.core.entity.StockScore; import java.time.LocalDate; +import java.util.Map; +import java.util.stream.Collectors; /** * @author lanyuanxiaoyao @@ -16,7 +18,8 @@ public record StockScoreVo( Stock.Market market, String industry, LocalDate listedDate, - Double score + Double score, + String extra ) { public static StockScoreVo of(StockScore score) { return new StockScoreVo( @@ -27,7 +30,13 @@ public record StockScoreVo( score.getStock().getMarket(), score.getStock().getIndustry(), score.getStock().getListedDate(), - score.getScore() + score.getScore(), + score.getExtra() + .entrySet() + .stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> entry.getKey() + ": " + entry.getValue()) + .collect(Collectors.joining("
")) ); } } diff --git a/leopard-web/src/pages/stock/StockCollectionDetail.tsx b/leopard-web/src/pages/stock/StockCollectionDetail.tsx index 3f9f94e..4dae1f3 100644 --- a/leopard-web/src/pages/stock/StockCollectionDetail.tsx +++ b/leopard-web/src/pages/stock/StockCollectionDetail.tsx @@ -21,10 +21,21 @@ function StockCollectionDetail() { undefined, [ { + className: 'white-space-pre', name: 'score', label: '得分', width: 50, align: 'center', + type: 'tpl', + tpl: '${score}', + popOver: { + trigger: 'click', + showIcon: false, + body: { + type: 'tpl', + tpl: '${extra|raw}', + }, + }, }, ], ),