diff --git a/leopard-server/src/test/resources/tushare.http b/leopard-server/src/test/resources/tushare.http index 2a0ac03..54f66b0 100644 --- a/leopard-server/src/test/resources/tushare.http +++ b/leopard-server/src/test/resources/tushare.http @@ -209,8 +209,8 @@ Content-Type: application/json "api_name": "fina_indicator", "token": "{{api_key}}", "params": { - "ts_code": "600132.SH", - "period": "20191231" + "ts_code": "001400.SZ", + "period": "20231231" }, "fields": [ "ts_code", @@ -238,3 +238,25 @@ Content-Type: application/json "resume_date" ] } + +### Get Index +POST {{api_url}} +Content-Type: application/json + +{ + "api_name": "index_basic", + "token": "{{api_key}}", + "params": { + "market": "CSI" + }, + "fields": [ + "ts_code", + "name", + "fullname", + "market", + "publisher", + "index_type", + "category", + "desc" + ] +} diff --git a/leopard-strategy/src/main/java/com/lanyuanxiaoyao/leopard/strategy/PyramidStockSelector.java b/leopard-strategy/src/main/java/com/lanyuanxiaoyao/leopard/strategy/PyramidStockSelector.java new file mode 100644 index 0000000..3a38ac0 --- /dev/null +++ b/leopard-strategy/src/main/java/com/lanyuanxiaoyao/leopard/strategy/PyramidStockSelector.java @@ -0,0 +1,224 @@ +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); + } +} diff --git a/leopard-strategy/src/main/java/com/lanyuanxiaoyao/leopard/strategy/StockAssessmentNode.java b/leopard-strategy/src/main/java/com/lanyuanxiaoyao/leopard/strategy/StockAssessmentNode.java new file mode 100644 index 0000000..5e6eded --- /dev/null +++ b/leopard-strategy/src/main/java/com/lanyuanxiaoyao/leopard/strategy/StockAssessmentNode.java @@ -0,0 +1,126 @@ +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 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 dailies) { + return (dailies.getLast().getHfqClose() - dailies.getFirst().getHfqClose()) / dailies.getFirst().getHfqClose(); + } + + private double getStd(List 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 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 stocks, Set 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(); + } + } + } +} diff --git a/leopard-strategy/src/main/java/com/lanyuanxiaoyao/leopard/strategy/StrategyApplication.java b/leopard-strategy/src/main/java/com/lanyuanxiaoyao/leopard/strategy/StrategyApplication.java index e52a196..5dd2116 100644 --- a/leopard-strategy/src/main/java/com/lanyuanxiaoyao/leopard/strategy/StrategyApplication.java +++ b/leopard-strategy/src/main/java/com/lanyuanxiaoyao/leopard/strategy/StrategyApplication.java @@ -1,50 +1,37 @@ package com.lanyuanxiaoyao.leopard.strategy; -import cn.hutool.core.map.MapUtil; +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.entity.Daily_; -import com.lanyuanxiaoyao.leopard.core.entity.QDaily; import com.lanyuanxiaoyao.leopard.core.repository.DailyRepository; import com.lanyuanxiaoyao.leopard.core.repository.StockRepository; +import com.yomahub.liteflow.core.FlowExecutor; import jakarta.annotation.Resource; import jakarta.transaction.Transactional; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.time.Duration; -import java.time.ZoneId; import java.util.ArrayList; -import java.util.List; import java.util.Map; import lombok.extern.slf4j.Slf4j; -import org.commonmark.Extension; -import org.commonmark.ext.gfm.tables.TablesExtension; -import org.commonmark.parser.Parser; -import org.commonmark.renderer.html.HtmlRenderer; 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.domain.Sort; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -import org.ta4j.core.BaseBar; -import org.ta4j.core.BaseBarSeriesBuilder; -import org.ta4j.core.indicators.SMAIndicator; -import org.ta4j.core.indicators.helpers.ClosePriceIndicator; @Slf4j @SpringBootApplication(scanBasePackages = "com.lanyuanxiaoyao.leopard") @EnableJpaAuditing public class StrategyApplication { - private static final List extensions = List.of(TablesExtension.create()); - private static final Parser parser = Parser.builder().extensions(extensions).build(); - private static final HtmlRenderer render = HtmlRenderer.builder().extensions(extensions).build(); private static final ObjectMapper mapper = new ObjectMapper(); @Resource private StockRepository stockRepository; @Resource private DailyRepository dailyRepository; + @Resource + private FlowExecutor flowExecutor; public static void main(String[] args) { SpringApplication.run(StrategyApplication.class, args); @@ -320,46 +307,22 @@ public class StrategyApplication { .sorted((t1, t2) -> Double.compare(Double.parseDouble(t2.get("increase")), Double.parseDouble(t1.get("increase")))) .toList(); render(Map.of("items", data));*/ - var dailies = dailyRepository.findAll( - QDaily.daily.stock.code.eq("000002.SZ") - .and(QDaily.daily.tradeDate.year().goe(2023)), - Sort.by(Daily_.TRADE_DATE) - ); - var barSeries = new BaseBarSeriesBuilder().build(); - dailies.forEach(daily -> barSeries.addBar(new BaseBar( - Duration.ofDays(1), - daily.getTradeDate().atStartOfDay().atZone(ZoneId.systemDefault()), - daily.getHfqOpen(), - daily.getHfqHigh(), - daily.getHfqLow(), - daily.getHfqClose(), - daily.getVolume() - ))); - var sma10 = new SMAIndicator(new ClosePriceIndicator(barSeries), 10); - var sma60 = new SMAIndicator(new ClosePriceIndicator(barSeries), 60); - var sma120 = new SMAIndicator(new ClosePriceIndicator(barSeries), 120); - var xList = new ArrayList(); - var yList = new ArrayList>(); - var sma10List = new ArrayList(); - var sma60List = new ArrayList(); - var sma120List = new ArrayList(); - for (int index = 0; index < dailies.size(); index++) { - var daily = dailies.get(index); - xList.add(daily.getTradeDate().toString()); - yList.add(List.of(daily.getHfqOpen(), daily.getHfqClose(), daily.getHfqLow(), daily.getHfqHigh())); - sma10List.add(sma10.getValue(index).doubleValue()); - sma60List.add(sma60.getValue(index).doubleValue()); - sma120List.add(sma120.getValue(index).doubleValue()); + var lines = new ArrayList(); + 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() + .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())); + } + for (int index = 0, year = 2010; index < lines.size(); index++, year++) { + log.info("胜率: {} {}", year, lines.get(index)); } - render( - MapUtil.builder() - .put("xList", xList) - .put("yList", yList) - .put("sma10", sma10List) - .put("sma60", sma60List) - .put("sma120", sma120List) - .build() - ); } } diff --git a/leopard-strategy/src/main/resources/logback-spring.xml b/leopard-strategy/src/main/resources/logback-spring.xml index 8587748..9a1d6e0 100644 --- a/leopard-strategy/src/main/resources/logback-spring.xml +++ b/leopard-strategy/src/main/resources/logback-spring.xml @@ -15,7 +15,7 @@ - +