1
0

feat: 建立批处理也能输出图表的套路

This commit is contained in:
2025-10-30 17:48:30 +08:00
parent 83574e1229
commit 53a6d33fd5
4 changed files with 233 additions and 292 deletions

View File

@@ -34,6 +34,15 @@
<artifactId>commonmark-ext-gfm-tables</artifactId>
<version>0.26.0</version>
</dependency>
<dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-extra</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@@ -1,23 +1,17 @@
package com.lanyuanxiaoyao.leopard.strategy;
import cn.hutool.core.util.ObjectUtil;
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.QStock;
import cn.hutool.core.lang.Dict;
import cn.hutool.extra.template.TemplateConfig;
import cn.hutool.extra.template.TemplateEngine;
import cn.hutool.extra.template.TemplateUtil;
import com.lanyuanxiaoyao.leopard.core.entity.QDaily;
import com.lanyuanxiaoyao.leopard.core.repository.DailyRepository;
import com.lanyuanxiaoyao.leopard.core.repository.StockRepository;
import com.lanyuanxiaoyao.leopard.core.service.AssessmentService;
import com.lanyuanxiaoyao.leopard.core.service.StockService;
import com.lanyuanxiaoyao.leopard.core.service.TuShareService;
import com.lanyuanxiaoyao.leopard.core.service.selector.PyramidStockSelector;
import com.lanyuanxiaoyao.leopard.core.strategy.TradeEngine;
import jakarta.annotation.Resource;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
@@ -32,296 +26,38 @@ import org.springframework.transaction.annotation.Transactional;
@SpringBootApplication(scanBasePackages = "com.lanyuanxiaoyao.leopard")
@EnableJpaAuditing
public class StrategyApplication {
private static final ObjectMapper mapper = new ObjectMapper();
@Resource
private PyramidStockSelector pyramidStockSelector;
@Resource
private AssessmentService assessmentService;
@Resource
private TuShareService tuShareService;
private static final TemplateEngine engine = TemplateUtil.createEngine(new TemplateConfig("templates", TemplateConfig.ResourceMode.CLASSPATH));
@Resource
private DailyRepository dailyRepository;
@Resource
private StockRepository stockRepository;
@Resource
private StockService stockService;
@Resource
private TradeEngine tradeEngine;
public static void main(String[] args) {
SpringApplication.run(StrategyApplication.class, args);
}
private static void render(Map<String, Object> data) throws IOException {
Files.writeString(
Path.of("result.html"),
StrUtil.format(
// language=HTML
"""
<html lang='zh'>
<head>
<meta charset='utf-8'/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta name='viewport' content='width=device-width, initial-scale=1.0'/>
<title>Strategy</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/amis/6.13.0/antd.min.css"/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/amis/6.13.0/helper.min.css"/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/amis/6.13.0/iconfont.min.css"/>
<style>
html, body, #root {
position: relative;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<div id='root'/>
</body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/amis/6.13.0/sdk.min.js"></script>
<script type='text/javascript'>
(function () {
const amis = amisRequire('amis/embed')
const amisJson = {
type: 'page',
title: 'Strategy',
data: {},
body: [
/*{
type: 'table2',
source: '${items}',
columns: [
{
name: 'date',
label: 'Date',
width: 100,
},
{
name: 'code',
label: 'Code',
width: 100,
},
{
name: 'name',
label: 'Name',
width: 100,
},
{
name: 'sma5',
label: 'SMA-5',
width: 100,
},
{
name: 'sma10',
label: 'SMA-10',
width: 100,
},
],
},*/
{
type: 'chart',
height: 800,
config: {
backgroundColor: '#fff',
animation: true,
animationDuration: 1000,
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
},
backgroundColor: 'rgba(0, 0, 0, 0.7)',
borderColor: '#333',
borderWidth: 1,
textStyle: {
color: '#fff',
fontSize: 12,
},
padding: 12,
formatter: function (params) {
const param = params[0]
const open = param.data[1].toFixed(2)
const close = param.data[2].toFixed(2)
const lowest = param.data[3].toFixed(2)
const highest = param.data[4].toFixed(2)
return `<div class="text-center font-bold mb-2">${param.name}</div>
<div class="text-center">
<span>开盘:</span>
<span class="font-bold ml-4">${open}</span>
</div>
<div class="text-center">
<span>收盘:</span>
<span class="font-bold ml-4">${close}</span>
</div>
<div class="text-center">
<span>最低:</span>
<span class="font-bold ml-4">${lowest}</span>
</div>
<div class="text-center">
<span>最高:</span>
<span class="font-bold ml-4">${highest}</span>
</div>`
},
},
grid: {
left: '2%',
right: '2%',
top: '5%',
bottom: '12%',
containLabel: true,
},
xAxis: {
data: '${xList || []}',
axisLine: {
lineStyle: {
color: '#e0e0e0',
},
},
axisLabel: {
color: '#666',
fontWeight: 'bold',
},
splitLine: {
show: false,
},
axisTick: {
show: false,
},
},
yAxis: {
scale: true,
axisLine: {
lineStyle: {
color: '#e0e0e0',
},
},
axisLabel: {
color: '#666',
fontWeight: 'bold',
formatter: function (value) {
return value.toFixed(2)
},
},
splitLine: {
lineStyle: {
type: 'dashed',
color: '#f0f0f0',
},
},
axisTick: {
show: false,
},
},
dataZoom: [
{
type: 'inside',
start: 0,
end: 100,
},
{
show: true,
type: 'slider',
top: '90%',
start: 0,
end: 100,
},
],
series: [
{
type: 'candlestick',
data: '${yList || []}',
itemStyle: {
color: '#eb5454',
color0: '#4aaa93',
borderColor: '#eb5454',
borderColor0: '#4aaa93',
borderWidth: 1,
},
},
{
type: 'line',
data: '${sma10 || []}',
smooth: true,
lineStyle: {
color: '#dcb38a',
},
symbol: 'none',
},
{
type: 'line',
data: '${sma60 || []}',
smooth: true,
lineStyle: {
color: '#6ce3c6',
},
symbol: 'none',
},
{
type: 'line',
data: '${sma120 || []}',
smooth: true,
lineStyle: {
color: '#6cd5e3',
},
symbol: 'none',
},
],
},
},
],
}
amis.embed('#root', amisJson, {}, {theme: 'antd'})
})()
</script>
</html>
""",
mapper.writeValueAsString(data)
)
);
}
@Transactional(readOnly = true)
@EventListener(ApplicationReadyEvent.class)
public void test() {
var stock = stockRepository.findOne(QStock.stock.code.eq("000001.SZ")).orElseThrow();
var asset = tradeEngine.backtest(
List.of(stock.getId()),
(now, currentAsset, dailies) -> {
return dailies.entrySet()
.stream()
.map(entry -> {
var stockId = entry.getKey();
var stockDailies = entry.getValue()
.stream()
.sorted(Comparator.comparing(Daily::getTradeDate))
.toList();
var yesterday = stockDailies.getLast();
if (yesterday.getHfqClose() > yesterday.getHfqOpen()) {
log.info("{} Buy for price {} {}", now, yesterday.getHfqOpen(), yesterday.getHfqClose());
return new TradeEngine.Trade(now, stockId, 100);
} else if (yesterday.getHfqClose() < yesterday.getHfqOpen()) {
var hold = currentAsset.getStocks().getOrDefault(stockId, 0);
if (hold > 0) {
log.info("{} Sell for price {} {}", now, yesterday.getHfqOpen(), yesterday.getHfqClose());
return new TradeEngine.Trade(now, stockId, -1 * hold);
}
} else {
log.info("{} Hold for price {} {}", now, yesterday.getHfqOpen(), yesterday.getHfqClose());
}
return null;
})
.filter(ObjectUtil::isNotNull)
.toList();
},
LocalDate.of(2024, 12, 1),
LocalDate.of(2024, 12, 31)
public void test() throws IOException {
var dailies = dailyRepository.findAll(
QDaily.daily.stock.code.eq("000001.SZ")
.and(QDaily.daily.tradeDate.after(LocalDate.of(2025, 1, 1))),
QDaily.daily.tradeDate.asc()
);
log.info("Final Cash: {}", asset.getCash());
for (var history : asset.getHistories()) {
log.info("Date: {} Cash: {} Trade: {}", history.date(), history.cash(), history.trades().values());
var xList = new ArrayList<String>();
var yList = new ArrayList<List<Double>>();
for (var daily : dailies) {
xList.add(daily.getTradeDate().toString());
yList.add(List.of(daily.getHfqOpen(), daily.getHfqClose(), daily.getHfqLow(), daily.getHfqHigh()));
}
var template = engine.getTemplate("report.html");
Files.writeString(Path.of("report.html"), template.render(
Dict.create().set(
"charts",
Dict.create()
.set("日线数据", Map.of("xList", xList, "yList", yList))
)
));
}
}

View File

@@ -0,0 +1,191 @@
<html lang='zh'>
<head>
<meta charset='utf-8'/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta name='viewport' content='width=device-width, initial-scale=1.0'/>
<title>Strategy</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/amis/6.13.0/antd.min.css"/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/amis/6.13.0/helper.min.css"/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/amis/6.13.0/iconfont.min.css"/>
<style>
html, body, #root {
position: relative;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<div id='root'></div>
</body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/amis/6.13.0/sdk.min.js"></script>
<script type='text/javascript' th:inline="javascript">
function candleChart(title, data) {
return {
type: 'service',
data: data,
body: {
className: 'mt-2',
type: 'chart',
height: 500,
config: {
title: {
text: title,
},
backgroundColor: '#fff',
animation: true,
animationDuration: 1000,
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
},
backgroundColor: 'rgba(0, 0, 0, 0.7)',
borderColor: '#333',
borderWidth: 1,
textStyle: {
color: '#fff',
fontSize: 12,
},
padding: 12,
formatter: function (params) {
const param = params[0]
const open = parseFloat(param.data[1]).toFixed(2)
const close = parseFloat(param.data[2]).toFixed(2)
const lowest = parseFloat(param.data[3]).toFixed(2)
const highest = parseFloat(param.data[4]).toFixed(2)
return `<div class="text-center font-bold mb-2">${param.name}</div>
<div class="text-center">
<span>开盘:</span>
<span class="font-bold ml-4">${open}</span>
</div>
<div class="text-center">
<span>收盘:</span>
<span class="font-bold ml-4">${close}</span>
</div>
<div class="text-center">
<span>最低:</span>
<span class="font-bold ml-4">${lowest}</span>
</div>
<div class="text-center">
<span>最高:</span>
<span class="font-bold ml-4">${highest}</span>
</div>`
},
},
grid: {
left: '2%',
right: '2%',
top: '15%',
bottom: '15%',
containLabel: true,
},
xAxis: {
data: '${xList || []}',
axisLine: {
lineStyle: {
color: '#e0e0e0',
},
},
axisLabel: {
color: '#666',
fontWeight: 'bold',
},
splitLine: {
show: false,
},
axisTick: {
show: false,
},
},
yAxis: [
{
scale: true,
axisLine: {
lineStyle: {
color: '#e0e0e0',
},
},
axisLabel: {
color: '#666',
fontWeight: 'bold',
formatter: function (value) {
return value.toFixed(2)
},
},
splitLine: {
lineStyle: {
type: 'dashed',
color: '#f0f0f0',
},
},
axisTick: {
show: false,
},
},
{
scale: true,
axisLine: {
show: false,
},
axisTick: {
show: false,
},
axisLabel: {
show: false,
},
splitLine: {
show: false,
},
},
],
dataZoom: [
{
type: 'inside',
start: 0,
end: 100,
},
{
show: true,
type: 'slider',
top: '90%',
start: 0,
end: 100,
},
],
series: [
{
type: 'candlestick',
data: '${yList || []}',
yAxisIndex: 0,
itemStyle: {
color: '#eb5454',
color0: '#4aaa93',
borderColor: '#eb5454',
borderColor0: '#4aaa93',
borderWidth: 1,
},
},
],
},
},
}
}
const data = /*[[${charts}]]*/ {};
(function () {
const amis = amisRequire('amis/embed')
const amisJson = {
type: 'page',
title: 'Strategy',
body: Object.keys(data)
.map(key => candleChart(key, data[key])),
}
amis.embed('#root', amisJson, {}, {theme: 'antd'})
})()
</script>
</html>