diff --git a/.idea/compiler.xml b/.idea/compiler.xml index c4dae1d..edd3629 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -85,78 +85,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -167,8 +95,6 @@ - - diff --git a/leopard-server/pom.xml b/leopard-server/pom.xml index e71c755..168e565 100644 --- a/leopard-server/pom.xml +++ b/leopard-server/pom.xml @@ -59,6 +59,15 @@ hutool-http + + io.github.ralfkonrad.quantlib_for_maven + quantlib + + + org.ta4j + ta4j-core + + com.mysql mysql-connector-j diff --git a/leopard-server/src/main/java/com/lanyuanxiaoyao/leopard/server/controller/StockCollectionController.java b/leopard-server/src/main/java/com/lanyuanxiaoyao/leopard/server/controller/StockCollectionController.java index 61f70de..ea5f218 100644 --- a/leopard-server/src/main/java/com/lanyuanxiaoyao/leopard/server/controller/StockCollectionController.java +++ b/leopard-server/src/main/java/com/lanyuanxiaoyao/leopard/server/controller/StockCollectionController.java @@ -1,13 +1,14 @@ package com.lanyuanxiaoyao.leopard.server.controller; -import com.lanyuanxiaoyao.leopard.core.entity.Stock; import com.lanyuanxiaoyao.leopard.core.entity.StockCollection; +import com.lanyuanxiaoyao.leopard.server.entity.StockDetailVo; import com.lanyuanxiaoyao.leopard.server.service.StockCollectionService; import com.lanyuanxiaoyao.leopard.server.service.StockService; import com.lanyuanxiaoyao.service.template.controller.SimpleControllerSupport; import java.util.HashSet; import java.util.Set; import java.util.function.Function; +import java.util.stream.Collectors; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -52,6 +53,9 @@ public class StockCollectionController extends SimpleControllerSupport stocks + Set stocks ) { } } diff --git a/leopard-server/src/main/java/com/lanyuanxiaoyao/leopard/server/controller/StockController.java b/leopard-server/src/main/java/com/lanyuanxiaoyao/leopard/server/controller/StockController.java index 5cb3f29..f2c49e7 100644 --- a/leopard-server/src/main/java/com/lanyuanxiaoyao/leopard/server/controller/StockController.java +++ b/leopard-server/src/main/java/com/lanyuanxiaoyao/leopard/server/controller/StockController.java @@ -2,6 +2,7 @@ package com.lanyuanxiaoyao.leopard.server.controller; import cn.hutool.core.bean.BeanUtil; import com.lanyuanxiaoyao.leopard.core.entity.Stock; +import com.lanyuanxiaoyao.leopard.server.entity.StockDetailVo; import com.lanyuanxiaoyao.leopard.server.helper.NumberHelper; import com.lanyuanxiaoyao.leopard.server.service.StockService; import com.lanyuanxiaoyao.service.template.controller.GlobalResponse; @@ -24,7 +25,7 @@ import org.springframework.web.bind.annotation.RestController; @Slf4j @RestController @RequestMapping("stock") -public class StockController extends SimpleControllerSupport { +public class StockController extends SimpleControllerSupport { private final StockService stockService; public StockController(StockService service, StockService stockService) { @@ -126,37 +127,14 @@ public class StockController extends SimpleControllerSupport listItemMapper() { + return StockDetailVo::of; } @Override - protected Function listItemMapper() { - return this::covert; - } - - @Override - protected Function detailItemMapper() { - return this::covert; - } - - public record DetailItem( - Long id, - String code, - String name, - String fullname, - Stock.Market market, - String industry, - LocalDate listedDate - ) { + protected Function detailItemMapper() { + return StockDetailVo::of; } public record FinanceItem( diff --git a/leopard-server/src/main/java/com/lanyuanxiaoyao/leopard/server/entity/StockDetailVo.java b/leopard-server/src/main/java/com/lanyuanxiaoyao/leopard/server/entity/StockDetailVo.java new file mode 100644 index 0000000..8355152 --- /dev/null +++ b/leopard-server/src/main/java/com/lanyuanxiaoyao/leopard/server/entity/StockDetailVo.java @@ -0,0 +1,30 @@ +package com.lanyuanxiaoyao.leopard.server.entity; + +import com.lanyuanxiaoyao.leopard.core.entity.Stock; +import java.time.LocalDate; + +/** + * @author lanyuanxiaoyao + * @version 20250917 + */ +public record StockDetailVo( + Long id, + String code, + String name, + String fullname, + Stock.Market market, + String industry, + LocalDate listedDate +) { + public static StockDetailVo of(Stock stock) { + return new StockDetailVo( + stock.getId(), + stock.getCode(), + stock.getName(), + stock.getFullname(), + stock.getMarket(), + stock.getIndustry(), + stock.getListedDate() + ); + } +} diff --git a/leopard-server/src/main/java/com/lanyuanxiaoyao/leopard/server/service/task/PyramidStockSelector.java b/leopard-server/src/main/java/com/lanyuanxiaoyao/leopard/server/service/task/PyramidStockSelector.java new file mode 100644 index 0000000..1a578a2 --- /dev/null +++ b/leopard-server/src/main/java/com/lanyuanxiaoyao/leopard/server/service/task/PyramidStockSelector.java @@ -0,0 +1,221 @@ +package com.lanyuanxiaoyao.leopard.server.service.task; + +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import com.lanyuanxiaoyao.leopard.core.entity.FinanceIndicator; +import com.lanyuanxiaoyao.leopard.core.entity.Stock; +import com.lanyuanxiaoyao.leopard.core.entity.StockCollection; +import com.lanyuanxiaoyao.leopard.core.repository.StockCollectionRepository; +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.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.math3.stat.descriptive.DescriptiveStatistics; + +/** + * 金字塔选股 + * + * @author lanyuanxiaoyao + * @version 20250917 + */ +@Slf4j +@LiteflowComponent("pyramid_stock_selector") +public class PyramidStockSelector extends NodeComponent { + private final StockRepository stockRepository; + private final StockCollectionRepository stockCollectionRepository; + + public PyramidStockSelector(StockRepository stockRepository, StockCollectionRepository stockCollectionRepository) { + this.stockRepository = stockRepository; + this.stockCollectionRepository = stockCollectionRepository; + } + + @Override + public void process() { + // 选择至少有最近5年财报的股票 + var stocks = stockRepository.findAllByIndicatorsSizeGreaterThanEqual(5); + var stocksMap = stocks.stream().collect(Collectors.toMap(Stock::getCode, stock -> stock)); + var scores = stocks.stream().map(Stock::getCode).collect(Collectors.toMap(code -> code, code -> 0)); + for (Stock stock : stocks) { + var recentIndicators = stock.getIndicators() + .stream() + .sorted((a, b) -> b.getYear() - a.getYear()) + .limit(5) + .toList(); + 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.getCode(), scores.get(stock.getCode()) + 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.getCode(), scores.get(stock.getCode()) + 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.getCode(), scores.get(stock.getCode()) + 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.getCode(), scores.get(stock.getCode()) + cashScore); + + if (ObjectUtil.isNotNull(latestIndicator.getDaysAccountsReceivableTurnover()) && latestIndicator.getDaysAccountsReceivableTurnover() <= 30) { + scores.put(stock.getCode(), scores.get(stock.getCode()) + 20); + } + if (ObjectUtil.isNotNull(latestIndicator.getDaysInventoryTurnover()) && latestIndicator.getDaysInventoryTurnover() <= 30) { + scores.put(stock.getCode(), scores.get(stock.getCode()) + 20); + } + if (ArrayUtil.isAllNotNull(latestIndicator.getDaysAccountsReceivableTurnover(), latestIndicator.getDaysInventoryTurnover())) { + if (latestIndicator.getDaysAccountsReceivableTurnover() + latestIndicator.getDaysInventoryTurnover() <= 40) { + scores.put(stock.getCode(), scores.get(stock.getCode()) + 20); + } else if (latestIndicator.getDaysAccountsReceivableTurnover() + latestIndicator.getDaysInventoryTurnover() <= 60) { + scores.put(stock.getCode(), scores.get(stock.getCode()) + 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.getCode(), scores.get(stock.getCode()) + 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.getCode(), scores.get(stock.getCode()) + 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.getCode(), scores.get(stock.getCode()) + 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.getCode(), scores.get(stock.getCode()) + cashAscendingScore); + } + var first50 = scores.entrySet() + .stream() + .sorted((e1, e2) -> e2.getValue() - e1.getValue()) + .limit(50) + .map(entry -> stocksMap.get(entry.getKey())) + .collect(Collectors.toSet()); + var collection = new StockCollection(); + collection.setName(StrUtil.format("金字塔选股 ({})", LocalDate.now())); + collection.setDescription(""); + collection.setStocks(first50); + stockCollectionRepository.save(collection); + } +} diff --git a/leopard-web/src/index.tsx b/leopard-web/src/index.tsx index aabfa12..90c7a0a 100644 --- a/leopard-web/src/index.tsx +++ b/leopard-web/src/index.tsx @@ -14,6 +14,7 @@ import TaskScheduleList from './pages/task/TaskScheduleList.tsx' import TaskScheduleSave from './pages/task/TaskScheduleSave.tsx' import StockCollectionList from './pages/stock/StockCollectionList.tsx' import TaskDetail from './pages/task/TaskDetail.tsx' +import StockCollectionDetail from './pages/stock/StockCollectionDetail.tsx' const routes: RouteObject[] = [ { @@ -46,6 +47,10 @@ const routes: RouteObject[] = [ path: 'list', Component: StockCollectionList, }, + { + path: 'detail/:id', + Component: StockCollectionDetail, + }, ], }, ], diff --git a/leopard-web/src/pages/stock/StockCollectionDetail.tsx b/leopard-web/src/pages/stock/StockCollectionDetail.tsx new file mode 100644 index 0000000..4032eb1 --- /dev/null +++ b/leopard-web/src/pages/stock/StockCollectionDetail.tsx @@ -0,0 +1,30 @@ +import React from "react" +import {amisRender, commonInfo, crudCommonOptions, paginationTemplate, stockListColumns} from '../../util/amis.tsx' +import {useNavigate, useParams} from 'react-router' + +function StockCollectionDetail() { + const navigate = useNavigate() + const {id} = useParams() + return ( +
+ {amisRender( + { + type: 'page', + title: '股票集详情', + initApi: `get:${commonInfo.baseUrl}/stock_collection/detail/${id}`, + body: [ + { + type: 'crud', + source: '${stocks}', + ...crudCommonOptions(), + ...paginationTemplate(15, undefined, ['filter-toggler']), + columns: stockListColumns(navigate), + } + ] + } + )} +
+ ) +} + +export default React.memo(StockCollectionDetail) \ No newline at end of file diff --git a/leopard-web/src/pages/stock/StockCollectionList.tsx b/leopard-web/src/pages/stock/StockCollectionList.tsx index 89f44b5..e3e5a2a 100644 --- a/leopard-web/src/pages/stock/StockCollectionList.tsx +++ b/leopard-web/src/pages/stock/StockCollectionList.tsx @@ -1,8 +1,71 @@ import React from "react" +import {amisRender, commonInfo, crudCommonOptions, paginationTemplate} from '../../util/amis.tsx' +import {useNavigate} from 'react-router' function StockCollectionList() { + const navigate = useNavigate() return ( -
+
+ {amisRender( + { + type: 'page', + title: '股票列表', + body: [ + { + type: 'crud', + api: { + method: 'get', + url: `${commonInfo.baseUrl}/stock_collection/list`, + }, + ...crudCommonOptions(), + ...paginationTemplate(15, undefined, ['filter-toggler']), + columns: [ + { + name: 'name', + label: '名称', + width: 200, + }, + { + name: 'description', + label: '描述', + }, + { + name: 'count', + label: '股票数量', + align: 'center', + width: 100, + }, + { + type: 'operation', + label: '操作', + width: 100, + buttons: [ + { + type: 'action', + label: '详情', + level: 'link', + onEvent: { + click: { + actions: [ + { + actionType: 'custom', + // @ts-ignore + script: (context, action, event) => { + navigate(`/stock/collection/detail/${context.props.data['id']}`) + }, + }, + ], + }, + }, + }, + ], + }, + ], + }, + ], + }, + )} +
) } diff --git a/leopard-web/src/pages/stock/StockList.tsx b/leopard-web/src/pages/stock/StockList.tsx index 4045462..20d386a 100644 --- a/leopard-web/src/pages/stock/StockList.tsx +++ b/leopard-web/src/pages/stock/StockList.tsx @@ -3,10 +3,9 @@ import { amisRender, commonInfo, crudCommonOptions, - date, paginationTemplate, - remoteMappings, remoteOptions, + stockListColumns, } from '../../util/amis.tsx' import {useNavigate} from 'react-router' @@ -97,65 +96,7 @@ function StockList() { }, ], }, - columns: [ - { - name: 'code', - label: '编号', - width: 150, - }, - { - name: 'name', - label: '简称', - width: 150, - }, - { - name: 'fullname', - label: '全名', - }, - { - name: 'market', - label: '市场', - width: 100, - align: 'center', - ...remoteMappings('stock_market', 'market'), - }, - { - name: 'industry', - label: '行业', - width: 80, - }, - { - label: '上市日期', - width: 100, - align: 'center', - ...date('listedDate'), - }, - { - type: 'operation', - label: '操作', - width: 100, - buttons: [ - { - type: 'action', - label: '详情', - level: 'link', - onEvent: { - click: { - actions: [ - { - actionType: 'custom', - // @ts-ignore - script: (context, action, event) => { - navigate(`/stock/detail/${context.props.data['id']}`) - }, - }, - ], - }, - }, - }, - ], - }, - ], + columns: stockListColumns(navigate), }, ], }, diff --git a/leopard-web/src/util/amis.tsx b/leopard-web/src/util/amis.tsx index dc699fa..c3ad331 100644 --- a/leopard-web/src/util/amis.tsx +++ b/leopard-web/src/util/amis.tsx @@ -5,6 +5,7 @@ import 'amis/sdk/iconfont.css' import '@fortawesome/fontawesome-free/css/all.min.css' import axios from 'axios' import {isEqual} from 'es-toolkit' +import type {NavigateFunction} from 'react-router' export const commonInfo = { debug: isEqual(import.meta.env.MODE, 'development'), @@ -334,3 +335,65 @@ export function remoteMappings(name: string, field: string) { source: `get:${commonInfo.baseUrl}/constants/mappings/${name}/${field}`, } } + +export function stockListColumns(navigate: NavigateFunction) { + return [ + { + name: 'code', + label: '编号', + width: 150, + }, + { + name: 'name', + label: '简称', + width: 150, + }, + { + name: 'fullname', + label: '全名', + }, + { + name: 'market', + label: '市场', + width: 100, + align: 'center', + ...remoteMappings('stock_market', 'market'), + }, + { + name: 'industry', + label: '行业', + width: 80, + }, + { + label: '上市日期', + width: 100, + align: 'center', + ...date('listedDate'), + }, + { + type: 'operation', + label: '操作', + width: 100, + buttons: [ + { + type: 'action', + label: '详情', + level: 'link', + onEvent: { + click: { + actions: [ + { + actionType: 'custom', + // @ts-ignore + script: (context, action, event) => { + navigate(`/stock/detail/${context.props.data['id']}`) + }, + }, + ], + }, + }, + }, + ], + }, + ] +} diff --git a/pom.xml b/pom.xml index 30d95d1..c5dc008 100644 --- a/pom.xml +++ b/pom.xml @@ -81,6 +81,17 @@ ${hibernate.version}
+ + io.github.ralfkonrad.quantlib_for_maven + quantlib + 1.39.0 + + + org.ta4j + ta4j-core + 0.17 + + org.springframework.boot spring-boot-dependencies