1
0

refactor: 重构任务执行

This commit is contained in:
2025-09-24 22:34:16 +08:00
parent 8011a4f2cb
commit 01690bbcd6
35 changed files with 610 additions and 1137 deletions

View File

@@ -1,224 +0,0 @@
package com.lanyuanxiaoyao.leopard.strategy;
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.repository.StockRepository;
import com.yomahub.liteflow.annotation.LiteflowComponent;
import com.yomahub.liteflow.core.NodeComponent;
import java.time.LocalDate;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.math3.stat.descriptive.DescriptiveStatistics;
import org.springframework.transaction.annotation.Transactional;
/**
* 金字塔选股
*
* @author lanyuanxiaoyao
* @version 20250917
*/
@Slf4j
@LiteflowComponent("pyramid_stock_selector")
public class PyramidStockSelector extends NodeComponent {
private final StockRepository stockRepository;
public PyramidStockSelector(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional(rollbackFor = Throwable.class)
@Override
public void process() {
var assessment = getContextBean(StockAssessmentNode.StockAssessment.class);
// 选择至少有最近5年财报的股票
// 有点奇怪001400.SZ有近5年的财报但资料显示是2025年才上市的
var stocks = stockRepository.findAll(QStock.stock.listedDate.before(LocalDate.of(assessment.year(), 1, 1)));
log.info("Year: {} Stock: {}", assessment.year(), stocks.size());
var scores = stocks.stream().collect(Collectors.toMap(stock -> stock, code -> 0));
for (Stock stock : stocks) {
var recentIndicators = stock.getIndicators()
.stream()
.filter(indicator -> indicator.getYear() < assessment.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);
}
var first50 = scores.entrySet()
.stream()
.sorted((e1, e2) -> e2.getValue() - e1.getValue())
.limit(50)
.map(Map.Entry::getKey)
.collect(Collectors.toSet());
assessment.stocks().addAll(first50);
}
}

View File

@@ -1,126 +0,0 @@
package com.lanyuanxiaoyao.leopard.strategy;
import cn.hutool.core.util.ObjectUtil;
import com.lanyuanxiaoyao.leopard.core.entity.Daily;
import com.lanyuanxiaoyao.leopard.core.entity.QDaily;
import com.lanyuanxiaoyao.leopard.core.entity.QStock;
import com.lanyuanxiaoyao.leopard.core.entity.Stock;
import com.lanyuanxiaoyao.leopard.core.repository.DailyRepository;
import com.lanyuanxiaoyao.leopard.core.repository.StockRepository;
import com.yomahub.liteflow.annotation.LiteflowComponent;
import com.yomahub.liteflow.core.NodeComponent;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.math3.stat.descriptive.DescriptiveStatistics;
import org.springframework.transaction.annotation.Transactional;
/**
* 股票评估
*
* @author lanyuanxiaoyao
* @version 20250919
*/
@Slf4j
@LiteflowComponent("assess_stock")
public class StockAssessmentNode extends NodeComponent {
private static final Map<String, Double> INDUSTRY_TOP = new HashMap<>();
private final StockRepository stockRepository;
private final DailyRepository dailyRepository;
public StockAssessmentNode(StockRepository stockRepository, DailyRepository dailyRepository) {
this.stockRepository = stockRepository;
this.dailyRepository = dailyRepository;
}
@Transactional(readOnly = true)
@Override
public void process() {
var assessment = getContextBean(StockAssessment.class);
if (ObjectUtil.isNotNull(assessment) && ObjectUtil.isNotEmpty(assessment.stocks())) {
var dailyMap = dailyRepository.findAll(
QDaily.daily.tradeDate.year().eq(assessment.year())
.and(QDaily.daily.stock.in(assessment.stocks()))
)
.stream()
.collect(Collectors.groupingBy(Daily::getStock));
for (Stock stock : assessment.stocks()) {
if (!dailyMap.containsKey(stock) || ObjectUtil.isEmpty(dailyMap.get(stock))) {
log.warn("Cannot find daily data in {} for {}", assessment.year(), stock.getCode());
continue;
}
var dailies = dailyMap.get(stock)
.stream()
.sorted(Comparator.comparing(Daily::getTradeDate))
.toList();
var change = getChange(dailies);
var std = getStd(dailies);
var industryTop = getTopOfIndustry(stock.getIndustry(), assessment.year());
assessment.results().add(new StockAssessment.Result(stock, change, std, industryTop));
}
}
}
private double getChange(List<Daily> dailies) {
return (dailies.getLast().getHfqClose() - dailies.getFirst().getHfqClose()) / dailies.getFirst().getHfqClose();
}
private double getStd(List<Daily> dailies) {
var statistics = new DescriptiveStatistics();
dailies.forEach(daily -> statistics.addValue(daily.getHfqClose()));
return statistics.getStandardDeviation();
}
private double getTopOfIndustry(String industry, int year) {
log.info("Calculate industry: {} for {}", industry, year);
if (INDUSTRY_TOP.containsKey(industry)) {
return INDUSTRY_TOP.get(industry);
}
var top = stockRepository.findAll(QStock.stock.industry.eq(industry))
.parallelStream()
.filter(stock -> stock.getListedDate().getYear() <= year)
.map(stock -> {
List<Daily> dailies = dailyRepository.findAll(
QDaily.daily.tradeDate.year().eq(year)
.and(QDaily.daily.stock.eq(stock))
);
if (ObjectUtil.isEmpty(dailies)) {
log.warn("Cannot find daily data in {} for {} {}", year, stock.getCode(), stock.getName());
}
return dailies;
})
.filter(ObjectUtil::isNotEmpty)
.map(this::getChange)
.mapToDouble(change -> change)
.max()
.orElse(0.0);
INDUSTRY_TOP.put(industry, top);
return top;
}
public record StockAssessment(int year, Set<Stock> stocks, Set<StockAssessment.Result> results) {
public StockAssessment(int year) {
this(year, new HashSet<>(), new HashSet<>());
}
public record Result(Stock stock, double change, double std, double industryTop) {
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
Result result = (Result) o;
return stock.equals(result.stock);
}
@Override
public int hashCode() {
return stock.hashCode();
}
}
}
}

View File

@@ -1,25 +1,25 @@
package com.lanyuanxiaoyao.leopard.strategy;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.NumberUtil;
import cn.hutool.core.util.StrUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lanyuanxiaoyao.leopard.core.repository.DailyRepository;
import com.lanyuanxiaoyao.leopard.core.repository.StockRepository;
import com.yomahub.liteflow.core.FlowExecutor;
import com.lanyuanxiaoyao.leopard.core.service.AssessmentService;
import com.lanyuanxiaoyao.leopard.core.service.selector.PyramidStockSelector;
import com.lanyuanxiaoyao.leopard.core.service.selector.StockSelector;
import jakarta.annotation.Resource;
import jakarta.transaction.Transactional;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@SpringBootApplication(scanBasePackages = "com.lanyuanxiaoyao.leopard")
@@ -27,11 +27,9 @@ import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
public class StrategyApplication {
private static final ObjectMapper mapper = new ObjectMapper();
@Resource
private StockRepository stockRepository;
private PyramidStockSelector pyramidStockSelector;
@Resource
private DailyRepository dailyRepository;
@Resource
private FlowExecutor flowExecutor;
private AssessmentService assessmentService;
public static void main(String[] args) {
SpringApplication.run(StrategyApplication.class, args);
@@ -268,9 +266,9 @@ public class StrategyApplication {
);
}
@Transactional(rollbackOn = Throwable.class)
@Transactional(readOnly = true)
@EventListener(ApplicationReadyEvent.class)
public void test() throws IOException {
public void test() {
/*var dailies = dailyRepository.findAll(
QDaily.daily.tradeDate.year().eq(2025),
Sort.by(Daily_.TRADE_DATE)
@@ -310,16 +308,20 @@ public class StrategyApplication {
var lines = new ArrayList<String>();
for (int year = 2024; year < 2025; year++) {
var assessment = new StockAssessmentNode.StockAssessment(year);
var response = flowExecutor.execute2RespWithEL("THEN(pyramid_stock_selector,assess_stock)", null, IdUtil.nanoId(), assessment);
assessment = response.getContextBean(StockAssessmentNode.StockAssessment.class);
int up = assessment.results()
.stream()
var candidates = pyramidStockSelector.select(new PyramidStockSelector.Request(year));
for (StockSelector.Candidate candidate : candidates) {
log.info("{} {}", candidate.stock().getName(), candidate.score());
}
var stocks = candidates.stream()
.map(StockSelector.Candidate::stock)
.collect(Collectors.toSet());
var results = assessmentService.assess(stocks, year);
int up = results.stream()
.filter(result -> result.change() > 0)
.mapToInt(result -> 1)
.sum();
lines.add(NumberUtil.roundStr(up * 100.0 / assessment.results().size(), 2));
assessment.results().forEach(result -> log.info("{} {} {} {} {}", result.stock().getCode(), result.stock().getName(), result.change(), result.std(), result.industryTop()));
lines.add(NumberUtil.roundStr(up * 100.0 / results.size(), 2));
results.forEach(result -> log.info("{} {} {} {} {}", result.stock().getCode(), result.stock().getName(), result.change(), result.std(), result.industryTop()));
}
for (int index = 0, year = 2010; index < lines.size(); index++, year++) {
log.info("胜率: {} {}", year, lines.get(index));