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}