1
0

feat: 增加额外得分数据保存

This commit is contained in:
2025-10-11 15:04:16 +08:00
parent 5390a879e7
commit 49a03adf21
6 changed files with 228 additions and 182 deletions

View File

@@ -2,10 +2,13 @@ package com.lanyuanxiaoyao.leopard.core.entity;
import com.lanyuanxiaoyao.leopard.core.Constants; import com.lanyuanxiaoyao.leopard.core.Constants;
import com.lanyuanxiaoyao.service.template.entity.SimpleEntity; import com.lanyuanxiaoyao.service.template.entity.SimpleEntity;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners; import jakarta.persistence.EntityListeners;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import java.util.Map;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import lombok.ToString; import lombok.ToString;
@@ -28,5 +31,8 @@ public class StockScore extends SimpleEntity {
private Stock stock; private Stock stock;
@ManyToOne @ManyToOne
private StockCollection collection; private StockCollection collection;
@ElementCollection
@JoinTable(name = Constants.DATABASE_PREFIX + "stock_score_extra")
private Map<String, String> extra;
private Double score; private Double score;
} }

View File

@@ -4,9 +4,11 @@ import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.ObjectUtil;
import com.lanyuanxiaoyao.leopard.core.entity.FinanceIndicator; import com.lanyuanxiaoyao.leopard.core.entity.FinanceIndicator;
import com.lanyuanxiaoyao.leopard.core.entity.QStock; 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 com.lanyuanxiaoyao.leopard.core.repository.StockRepository;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -34,185 +36,202 @@ public class PyramidStockSelector implements StockSelector<PyramidStockSelector.
public Set<Candidate> select(Request request) { public Set<Candidate> select(Request request) {
// 选择至少有最近5年财报的股票 // 选择至少有最近5年财报的股票
// 有点奇怪001400.SZ有近5年的财报但资料显示是2025年才上市的 // 有点奇怪001400.SZ有近5年的财报但资料显示是2025年才上市的
var stocks = stockRepository.findAll(QStock.stock.listedDate.before(LocalDate.of(request.year(), 1, 1))); return 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()
.stream() .stream()
.sorted((e1, e2) -> e2.getValue() - e1.getValue()) .map(stock -> {
.limit(request.limit()) var extra = new HashMap<String, String>();
.map(entry -> new Candidate(entry.getKey(), entry.getValue())) 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()); .collect(Collectors.toSet());
} }

View File

@@ -13,7 +13,7 @@ import java.util.Set;
public interface StockSelector<T> { public interface StockSelector<T> {
Set<Candidate> select(T request); Set<Candidate> select(T request);
record Candidate(Stock stock, double score, Map<String, Object> extra) { record Candidate(Stock stock, double score, Map<String, String> extra) {
public Candidate(Stock stock, double score) { public Candidate(Stock stock, double score) {
this(stock, score, Map.of()); this(stock, score, Map.of());
} }

View File

@@ -45,6 +45,7 @@ public class PyramidSelect extends TaskRunner {
var score = new StockScore(); var score = new StockScore();
score.setStock(candidate.stock()); score.setStock(candidate.stock());
score.setScore(candidate.score()); score.setScore(candidate.score());
score.setExtra(candidate.extra());
score.setCollection(collection); score.setCollection(collection);
return score; return score;
}) })

View File

@@ -3,6 +3,8 @@ package com.lanyuanxiaoyao.leopard.server.entity;
import com.lanyuanxiaoyao.leopard.core.entity.Stock; import com.lanyuanxiaoyao.leopard.core.entity.Stock;
import com.lanyuanxiaoyao.leopard.core.entity.StockScore; import com.lanyuanxiaoyao.leopard.core.entity.StockScore;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Map;
import java.util.stream.Collectors;
/** /**
* @author lanyuanxiaoyao * @author lanyuanxiaoyao
@@ -16,7 +18,8 @@ public record StockScoreVo(
Stock.Market market, Stock.Market market,
String industry, String industry,
LocalDate listedDate, LocalDate listedDate,
Double score Double score,
String extra
) { ) {
public static StockScoreVo of(StockScore score) { public static StockScoreVo of(StockScore score) {
return new StockScoreVo( return new StockScoreVo(
@@ -27,7 +30,13 @@ public record StockScoreVo(
score.getStock().getMarket(), score.getStock().getMarket(),
score.getStock().getIndustry(), score.getStock().getIndustry(),
score.getStock().getListedDate(), score.getStock().getListedDate(),
score.getScore() score.getScore(),
score.getExtra()
.entrySet()
.stream()
.sorted(Map.Entry.comparingByKey())
.map(entry -> entry.getKey() + ": " + entry.getValue())
.collect(Collectors.joining("<br>"))
); );
} }
} }

View File

@@ -21,10 +21,21 @@ function StockCollectionDetail() {
undefined, undefined,
[ [
{ {
className: 'white-space-pre',
name: 'score', name: 'score',
label: '得分', label: '得分',
width: 50, width: 50,
align: 'center', align: 'center',
type: 'tpl',
tpl: '${score}',
popOver: {
trigger: 'click',
showIcon: false,
body: {
type: 'tpl',
tpl: '${extra|raw}',
},
},
}, },
], ],
), ),