Java量化系列(八):实现自选股最近10天行情涨跌邮件推送

0 阅读15分钟

在前七篇内容中,我们已经搭建了股票数据爬取、自选股管理、统计分析等核心功能,从“获取数据”“分析数据”升级到“可视化数据”。但对忙碌的投资者来说,每天主动打开系统查看行情仍有门槛——有没有办法让关键信息“主动找上门”?答案是肯定的!

本文作为Java量化系列的第八篇,将聚焦“自选股涨跌信息自动推送”功能的落地实现:核心是工作日18:30自动抓取自选股最近10天的涨幅数据,通过Velocity模板渲染成美观的HTML报表,最终推送至个人邮箱。从此无需手动查询,每天下班就能收到专属的自选股涨跌汇总,轻松跟踪持仓趋势。

一、核心需求与整体设计思路

1.1 功能核心价值

自选股邮件推送功能的核心是“解放双手,信息触达”,精准匹配三类需求:

  • 定时自动化:无需手动操作,工作日18:30(收盘后30分钟)自动执行,避免遗漏行情;
  • 数据可视化:通过HTML表格展示最近10天涨跌数据,红涨绿跌清晰直观,对比分析更高效;
  • 便捷可追溯:邮件天然支持历史归档,后续想回顾某段时间的持仓表现,直接查阅邮件即可。

1.2 核心需求拆解

结合实际使用场景,明确三大核心需求:

  • 定时调度:按Cron表达式 1 30 18 ? * 1-5 执行,即工作日18:30:01触发推送任务;
  • 数据处理:查询指定用户的自选股列表,获取每只股票最近10个交易日的涨幅数据,处理成邮件适配格式;
  • 邮件推送:采用Velocity模板渲染HTML报表,支持红涨绿跌高亮展示,稳定发送至用户邮箱;
  • 异常处理:邮件发送失败时记录错误日志,确保问题可追溯。

1.3 整体技术架构

本功能基于此前的量化框架扩展,核心依赖“定时调度组件”“自选股服务”“统计服务”“邮件服务”四大模块,整体流程如下:

定时任务触发(18:30 工作日)→ 查询用户自选股列表 → 抓取最近10天涨幅数据 → 数据格式转换(适配邮件展示)→ Velocity模板渲染HTML → 调用邮件服务发送 → 发送结果日志记录
1

核心依赖组件:

  • 定时调度:Spring Scheduler(基于Cron表达式实现精准定时);
  • 数据层:StockSelectedService(自选股查询)、StatBusiness(10天涨幅数据统计);
  • 模板渲染:Velocity(高效渲染HTML邮件模板,支持动态数据填充);
  • 邮件发送:Spring Boot Starter Mail(封装JavaMailSender,简化邮件发送逻辑);
  • 工具类:DateUtil(日期处理)、CollUtil(集合操作)、BeanUtil(对象转Map)。

二、核心实现(一):定时调度配置与触发逻辑

定时调度是功能自动化的核心,关键在于Cron表达式的精准配置和任务触发后的流程启动。

2.1 Cron表达式解析与配置

本次需求的Cron表达式为:1 30 18 ? * 1-5,从左到右逐位解析:

  • 1:秒位,精确到1秒触发,避免因任务重叠导致重复执行;
  • 30:分位,即30分触发;
  • 18:时位,即18点(下午6点)触发;
  • ?:日位,不指定具体日期(因星期位已指定,日位用?占位);
  • *:月位,所有月份都执行;
  • 1-5:星期位,仅周一到周五(工作日)执行,避开周末。

在Spring Boot中,只需在任务方法上添加@Scheduled(cron = "1 30 18 ? * 1-5")注解即可启用定时调度(需确保启动类添加@EnableScheduling注解)。

2.2 定时任务核心入口逻辑

定时任务触发后,核心流程为“获取用户信息→查询自选股10天涨幅数据→数据格式处理→模板渲染→邮件发送”。以下是入口方法的核心逻辑:

/**
* 工作日18:30自动推送自选股最近10天涨跌数据到邮箱
*/
@Scheduled(cron = "1 30 18 ? * 1-5")
public void autoPushStockTenDayAmplitude() {
// 1. 获取目标用户信息(实际场景可支持多用户,此处以单用户为例,多用户可查询用户表遍历)
UserDto userDto = userService.getById(1); // 假设用户ID为1,实际可配置化
if (userDto == null || StrUtil.isBlank(userDto.getEmail())) {
log.error("用户信息不存在或邮箱为空,推送失败");
return;
}

// 2. 构造查询参数,获取自选股最近10天涨幅数据
StatTen10Ro statTen10Ro = new StatTen10Ro();
statTen10Ro.setUserId(userDto.getId()); // 指定用户ID,查询其自选股
statTen10Ro.setPageSize(30); // 每页最多30只股票,满足多数用户需求
statTen10Ro.setPageNum(1); // 第一页
OutputResult<PageResponse<StockRelationVo>> tenDataResult = getTenTradeData(statTen10Ro);

// 3. 校验数据,无自选股数据则直接返回
if (tenDataResult == null || tenDataResult.getData() == null
|| CollUtil.isEmpty(tenDataResult.getData().getList())) {
log.info("用户{}无自选股数据,无需推送", userDto.getAccount());
return;
}
List<StockRelationVo> stockTen10List = tenDataResult.getData().getList();

// 4. 数据格式处理,适配邮件模板展示
List<StockRelationVo> convertTen10VoList = formatStockDataForEmail(stockTen10List);

// 5. 获取最近10个交易日日期,转换为简洁格式(如"01"而非"2025-12-01")
OutputResult<List<String>> tenTradeDayResult = holidayCalendarBusiness.getTenTradeDay();
if (tenTradeDayResult == null || CollUtil.isEmpty(tenTradeDayResult.getData())) {
log.error("获取最近10个交易日失败,推送终止");
return;
}
List<String> currDateList = tenTradeDayResult.getData();
List<String> convertDateList = currDateList.stream()
.map(date -> date.substring(8)) // 截取日期后两位,如"2025-12-01"→"01"
.collect(Collectors.toList());

// 6. 构建邮件内容DTO,封装所有需要渲染的数据
StockTenToEmailDto emailDto = buildEmailDataDto(userDto, convertTen10VoList, convertDateList);

// 7. 渲染Velocity模板并发送邮件
sendStockAmplitudeEmail(emailDto);
}
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647

三、核心实现(二):自选股10天涨幅数据查询与格式处理

数据是邮件推送的核心,这一步需要完成“查询自选股列表→获取单只股票10天涨幅→数据格式适配”三个关键操作。

3.1 核心查询方法:getTenTradeData

该方法负责查询指定用户的自选股列表,并关联每只股票最近10天的涨跌数据,核心逻辑是“分页查询自选股→转换为涨幅数据格式→封装分页结果”。

/**
* 查询用户自选股最近10天的涨跌数据
* @param statTen10Ro 包含用户ID、分页参数的查询对象
* @return 分页后的自选股涨跌数据
*/
public OutputResult<PageResponse<StockRelationVo>> getTenTradeData(StatTen10Ro statTen10Ro) {
// 1. 分页查询用户自选股列表
StockSelectedRo stockSelectedRo = new StockSelectedRo();
stockSelectedRo.setUserId(statTen10Ro.getUserId());
stockSelectedRo.setPageNum(statTen10Ro.getPageNum());
stockSelectedRo.setPageSize(statTen10Ro.getPageSize());
OutputResult<PageResponse<StockSelectedVo>> selectedResult =
stockSelectedService.pageSelected(stockSelectedRo);

// 2. 校验自选股数据,无数据则返回空分页
List<StockSelectedVo> selectedVoList = selectedResult.getData().getList();
if (CollUtil.isEmpty(selectedVoList)) {
return OutputResult.buildSucc(PageResponse.emptyPageResponse());
}

// 3. 转换自选股数据为10天涨幅数据格式
List<StockRelationVo> relationVoList = convertTen10VoBySelectedVo(selectedVoList);

// 4. 封装分页结果(复用原自选股查询的总条数,保证分页准确性)
PageInfo pageInfo = new PageInfo<>(relationVoList);
return OutputResult.buildSucc(new PageResponse<>(
selectedResult.getData().getTotal(), pageInfo.getList()
));
}
1234567891011121314151617181920212223242526272829

3.2 数据转换方法:convertTen10VoBySelectedVo

该方法负责将“自选股基础信息”转换为“包含10天涨幅细节的关联数据”,采用同步集合确保线程安全,同时通过try-catch捕获单只股票处理异常,避免影响整体推送。

/**
* 将自选股信息转换为包含10天涨跌细节的VO
* @param stockSelectedVoList 自选股基础信息列表
* @return 包含涨跌细节的自选股数据列表
*/
private List<StockRelationVo> convertTen10VoBySelectedVo(List<StockSelectedVo> stockSelectedVoList) {
// 同步集合,确保多线程环境下数据安全(实际场景可根据需求开启多线程处理)
List<StockRelationVo> ten10VoList = Collections.synchronizedList(
new ArrayList<>(stockSelectedVoList.size())
);

// 遍历自选股,逐只处理10天涨幅数据
for (StockSelectedVo selectedVo : stockSelectedVoList) {
try {
// 单只股票转换:查询并封装10天涨跌细节(核心方法,下文省略实现)
StockRelationVo relationVo = singleToTen10Vo(selectedVo);
ten10VoList.add(relationVo);
} catch (Exception e) {
// 单只股票处理失败不影响整体,记录错误日志
log.error("处理股票{}涨跌数据失败", selectedVo.getCode(), e);
} finally {
// 可添加资源释放逻辑(若有)
}
}

// 按股票代码排序,保证邮件中展示顺序一致
return ten10VoList.stream()
.sorted(Comparator.comparing(StockRelationVo::getCode))
.collect(Collectors.toList());
}
123456789101112131415161718192021222324252627282930

3.3 邮件格式适配:formatStockDataForEmail

查询到的原始数据需经过格式处理,才能适配邮件模板的展示需求:移除涨幅百分比符号、截取股票名称(避免过长)、确保数据整洁。

/**
* 处理股票数据格式,适配邮件展示
* @param stockTen10List 原始10天涨幅数据列表
* @return 格式化后的邮件适配数据
*/
private List<StockRelationVo> formatStockDataForEmail(List<StockRelationVo> stockTen10List) {
return stockTen10List.stream().map(stock -> {
StockRelationVo formatVo = new StockRelationVo();
// 1. 获取原始涨跌细节列表
List<HistoryRelationVo> detailList = stock.getDetailList();
if (CollUtil.isNotEmpty(detailList)) {
// 移除涨幅比例中的百分比符号(模板中已标注%,避免重复)
detailList.forEach(detail -> {
String amplitude = detail.getAmplitudeProportion();
if (StrUtil.isNotBlank(amplitude) && amplitude.endsWith("%")) {
detail.setAmplitudeProportion(amplitude.substring(0, amplitude.length() - 2));
}
});
}
// 2. 截取股票名称:最多保留4个字符,避免邮件表格列宽过大
String stockName = stock.getName();
formatVo.setName(stockName.length() > 4 ? stockName.substring(0, 4) : stockName);
// 3. 保留核心字段
formatVo.setCode(stock.getCode());
formatVo.setDetailList(detailList);
return formatVo;
}).collect(Collectors.toList());
}
@Data
@Schema(description ="股票最近交易信息Vo")
public class StockRelationVo implements Serializable {
@Schema(description ="股票编码")
private String code;
@Schema(description ="股票名称")
private String name;

@Schema(description = "成绩")
private Integer score;

@Schema(description = "涨幅")
private Double zt;

@Schema(description = "当前日期")
private Date currDate;

@Schema(description ="涨跌信息")
private List<HistoryRelationVo> detailList;

@Schema(description = "地址信息")
private String webUrl;

@Schema(description ="展示的股票编码")
private String showCode;
}
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556

四、核心实现(三):Velocity模板设计与邮件渲染

邮件展示效果的关键在于模板设计,我们采用Velocity模板引擎——它支持动态数据填充、条件判断、循环遍历,能轻松渲染出美观的HTML报表。

4.1 核心DTO构建:StockTenToEmailDto

先构建邮件数据DTO,封装所有需要渲染到模板的信息,确保模板数据来源清晰。

/**
* 自选股10天涨幅邮件DTO
*/
@Data
public class StockTenToEmailDto {
private String account; // 用户账号
private String name; // 用户名(用于邮件称呼)
private List<StockRelationVo> dataList; // 格式化后的股票涨跌数据
private List<String> currDateList; // 最近10个交易日(简洁格式,如"01")
private String line; // 换行符(适配HTML换行)
}
1234567891011

DTO构建方法:将用户信息、格式化股票数据、日期列表封装成DTO,再转换为Map供Velocity模板使用。

/**
* 构建邮件内容DTO
* @param userDto 用户信息
* @param convertTen10VoList 格式化股票数据
* @param convertDateList 简洁日期列表
* @return 邮件数据DTO
*/
private StockTenToEmailDto buildEmailDataDto(UserDto userDto,
List<StockRelationVo> convertTen10VoList,
List<String> convertDateList) {
StockTenToEmailDto emailDto = new StockTenToEmailDto();
emailDto.setAccount(userDto.getAccount());
emailDto.setName(userDto.getName());
emailDto.setDataList(convertTen10VoList);
emailDto.setCurrDateList(convertDateList);
emailDto.setLine("<br/>"); // HTML换行符,适配模板换行需求
return emailDto;
}
123456789101112131415161718

4.2 Velocity模板设计:stock_ten10.vm

模板核心是HTML表格设计,支持红涨绿跌高亮展示,同时添加简洁的样式确保在不同邮箱客户端(网易、QQ、企业邮箱)中正常显示。

<!DOCTYPE html>
<html lang="zh">
<head>
<META http-equiv=Content-Type content='text/html; charset=UTF-8'>
<title>交易日涨跌记录</title>
<style type="text/css">
/* 表格样式:边框合并,宽度100% */
table.reference {
border-collapse: collapse;
width: 100%;
margin-bottom: 4px;
margin-top: 4px;
}
/* 奇偶行背景色交替,提升可读性 */
table.reference tr:nth-child(even) {
background-color: #fff;
}
table.reference tr:nth-child(odd) {
background-color: #f6f4f0;
}
/* 单元格样式:边框、内边距、垂直对齐 */
table.reference td {
line-height: 2em;
min-width: 40px;
border: 1px solid #d4d4d4;
padding: 5px;
padding-top: 7px;
padding-bottom: 7px;
vertical-align: top;
}
</style>
</head>
<body>
<h3>亲爱的 <span style="color:red;">${name} </span> 投资者:</h3>
${line}
愿你明天心想事成,股票都是红
<br/>
${line}
您自选股票十个交易日内涨跌记录如下:
${line}
<div>
${line}
<table class="reference">
<tr>
<td width="60px;">名称</td>
#if(${currDateList})
#foreach($currDate in ${currDateList})
<td width="55px;">${currDate} (%)</td>
#end
#end
</tr>
#if(${dataList})
#foreach($stockInfo in ${dataList})
<tr>
<td>${stockInfo.code} ${line} ${stockInfo.name}</td>
#if(${stockInfo.detailList})
#foreach($ten10Vo in ${stockInfo.detailList})
<td>
#if(${ten10Vo.type} ==1)
<span style="color:red;"></span>
${line}
<span style="color:red;">${ten10Vo.amplitudeProportion} </span>
#elseif(${ten10Vo.type} ==-1)
<span style="color:green;"></span>
${line}
<span style="color:green;">${ten10Vo.amplitudeProportion} </span>
#elseif(${ten10Vo.type} ==0)
<span style="color:#999999;"></span>
${line}
<span style="color:#999999;">${ten10Vo.amplitudeProportion} </span>
#else
<span style="color:pink;"> </span>
${line}
0.00
#end
</td>
#end
#end
</tr>
#end
#end
</table>
</div>
</body>
</html>
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485

4.3 模板渲染核心逻辑

通过VelocityEngine加载模板文件,将邮件DTO转换为Map(VelocityContext),最终渲染成HTML字符串供邮件发送使用。

/**
* 渲染Velocity邮件模板,生成HTML内容
* @param velocityTemplateType 模板类型(此处为TEN10,对应stock_ten10.vm)
* @param dataMap 模板数据Map(由StockTenToEmailDto转换而来)
* @return 渲染后的HTML字符串
*/
private String getVelocityMailText(VelocityTemplateType velocityTemplateType, Map<String, Object> dataMap) {
// 1. 构建Velocity上下文,传入数据Map
VelocityContext velocityContext = new VelocityContext(dataMap);
// 2. 字符流用于接收渲染结果
StringWriter writer = new StringWriter();
// 3. 拼接模板路径:固定前缀+模板编码+后缀(如"stock_ten10.vm")
String templateLocation = "stock_" + velocityTemplateType.getCode() + ".vm";
// 4. 加载模板并渲染
velocityEngine.mergeTemplate(templateLocation, "UTF-8", velocityContext, writer);
return writer.toString();
}
1234567891011121314151617

五、核心实现(四):邮件发送功能实现

邮件发送是最终环节,基于Spring Boot Starter Mail封装核心逻辑,支持HTML格式内容发送,确保邮件稳定触达。

5.1 邮件发送核心方法

/**
* 发送Velocity模板邮件(入口方法)
* @param toArr 收件人邮箱数组(支持多收件人)
* @param subject 邮件主题
* @param velocityTemplateType 模板类型
* @param dataMap 模板数据
* @return 发送结果(true成功,false失败)
*/
public boolean sendVelocityMail(String[] toArr, String subject,
VelocityTemplateType velocityTemplateType, Map<String, Object> dataMap) {
try {
// 1. 渲染HTML邮件内容
String htmlContent = getVelocityMailText(velocityTemplateType, dataMap);
// 2. 调用HTML邮件发送方法
return sendHtmlMail(toArr, subject, htmlContent);
} catch (Exception ex) {
log.error("发送模板邮件失败,模板类型:{}", velocityTemplateType.getCode(), ex);
return false;
}
}

/**
* 发送HTML格式邮件
* @param toArr 收件人邮箱
* @param subject 邮件主题
* @param content HTML内容
* @return 发送结果
*/
@Override
public boolean sendHtmlMail(String[] toArr, String subject, String content) {
// 1. 创建MIME邮件对象(支持HTML、附件等复杂格式)
MimeMessage mimeMessage = javaMailSender.createMimeMessage();
try {
// 2. 构建邮件助手(true表示支持多部分内容)
MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true);
helper.setTo(toArr); // 收件人
helper.setSubject(subject); // 主题(如"自选股票十个交易日内涨跌记录")
helper.setText(content, true); // 内容(true表示HTML格式)
helper.setFrom(from); // 发件人邮箱(配置在application.yml中)
// 3. 发送邮件
javaMailSender.send(mimeMessage);
log.info("邮件发送成功,收件人:{},主题:{}", Arrays.toString(toArr), subject);
return true;
} catch (MessagingException e) {
log.error("发送HTML邮件失败", e);
return false;
}
}
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748

5.2 邮件发送配置(application.yml)

需在配置文件中配置发件人信息、邮件服务器地址、端口、授权码等核心参数(以QQ邮箱为例):

spring:
# 配置发送邮件# 配置发送邮件
mail:
host: smtp.qq.com # smtp.qq.com 对应的ip
username: xxxx@qq.com
password: xxxxxx
port: 465
default-encoding: utf-8
protocol: smtp
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true
ssl:
enable: true
socketFactory:
port: 465
class: javax.net.ssl.SSLSocketFactory
velocity:
FILE_RESOURCE_LOADER_PATH: src/main/resources/templates
1234567891011121314151617181920212223

六、核心优化点与生产环境适配

上述实现已满足基础的邮件推送需求,若要在生产环境使用,需补充以下优化点:

  • 多用户支持:当前以单用户为例,生产环境可查询用户表获取所有开通推送服务的用户,通过线程池异步处理多用户推送,提升效率;
  • 失败重试机制:邮件发送失败时添加重试逻辑(如最多重试3次,每次间隔5秒),可通过Spring Retry实现;
  • 模板缓存优化:Velocity模板可添加缓存,避免每次推送都重新加载模板文件,提升渲染速度;
  • 配置化管理:将Cron表达式、分页大小、模板路径等参数放入配置文件,后续调整无需修改代码;
  • 监控告警:添加推送结果监控,若连续多次推送失败,触发短信/钉钉告警,确保问题及时发现;
  • 附件支持:可扩展添加“Excel报表附件”功能,用户可下载自选股涨跌数据进行离线分析。

七、系列文章预告

本文完成了量化系统的“信息触达层”搭建,实现了自选股涨跌数据的自动化邮件推送,从此关键行情信息不再遗漏。下一篇文章将聚焦“系统监控与异常处理”,搭建全面的监控体系:包括任务执行状态监控、数据抓取失败告警、服务器资源监控等,确保量化系统稳定运行!

最后,留一个思考问题:在多用户场景下,如何避免大量用户同时推送导致的邮件服务器限流?欢迎在评论区交流你的解决方案~