Java量化系列(5-10)
Java量化系列(五):实现股票详细信息实时获取_java获取股票实时行情-CSDN博客
在第四篇文章中,我们完成了自选股票维护功能,用户已能便捷管理核心关注标的。但对量化交易者而言,仅维护自选列表远远不够——还需实时掌握单只股票的关键信息,比如当前价格、涨跌幅度、成交量、市盈率等,这些数据是判断标的短期走势、执行策略信号的核心依据。
本文作为Java量化系列的第五篇,将聚焦“股票详细信息获取”模块,基于SpringBoot技术栈,实现两大核心目标:一是定义统一的股票详情数据模型(StockShowInfoDto),兼容不同数据源的字段映射;二是对接财联社、腾讯两大公开数据源,实现股票详情信息的实时爬取、解析与封装。同时会复用系列第三篇中的历史数据爬取基础代码,保证系统代码的复用性与一致性,适合有一定Java爬虫与量化开发基础的工程师参考。
一、核心需求与数据模型设计
1.1 核心需求边界
股票详细信息获取的核心诉求是“实时性”“完整性”“兼容性”,结合量化交易场景,明确需求边界如下:
- 数据完整性:覆盖股票基础信息(代码、名称、全代码、交易所类型)、行情数据(开盘价、收盘价、最高价、最低价、当前价)、交易数据(成交量、成交金额、换手率、量比)、估值数据(市盈率)、辅助判断数据(涨跌幅度、是否涨停);
- 多数据源兼容:对接财联社、腾讯两大数据源,支持数据源切换(避免单一数据源失效导致功能不可用);
- 实时性保障:通过HTTP请求实时爬取数据源接口,确保获取的是最新行情数据(非历史缓存);
- 自动计算与封装:部分字段(如涨停状态、涨跌幅度百分比)需基于原始数据自动计算,简化上层调用;
- 异常兼容:爬取失败时返回null并记录日志,支持后续降级处理(如切换备用数据源)。
1.2 核心数据模型:StockShowInfoDto
为统一不同数据源的返回格式,定义StockShowInfoDto作为股票详细信息的标准输出模型,包含基础信息、行情数据、交易数据等全量字段,并通过自定义getter方法实现部分字段的自动计算(如涨停状态、涨跌幅度百分比转换)。
核心代码实现:
-
@Data
-
@Schema(description = "股票展示信息")
-
public class StockShowInfoDto implements Serializable {
-
/** 股票的代码(如001318) */
-
@Schema(description = "股票的代码")
-
private String code;
-
-
/** 股票的名称(如三峡能源) */
-
@Schema(description = "股票的名称")
-
private String name;
-
-
/** 当前查询日期 */
-
@Schema(description = "日期")
-
private String currDate;
-
-
/** 股票的全代码(如sz001318、sh600000) */
-
@Schema(description = "股票的全代码")
-
private String fullCode;
-
-
/** 交易所类型(0-未知/1-深交所/2-上交所,可自定义) */
-
@Schema(description = "交易所类型")
-
private Integer exchange;
-
-
/** 行情日期(当日) */
-
@Schema(description = "当前天")
-
private String date;
-
-
/** 开盘价 */
-
@Schema(description = "开盘价")
-
private BigDecimal openingPrice;
-
-
/** 昨天的收盘价 */
-
@Schema(description = "昨天的收盘价")
-
private BigDecimal yesClosingPrice;
-
-
/** 最高价格 */
-
@Schema(description = "最高价格")
-
private BigDecimal highestPrice;
-
-
/** 最低价格 */
-
@Schema(description = "最低价格")
-
private BigDecimal lowestPrice;
-
-
/** 收盘价(盘后有效,盘中取当前价) */
-
@Schema(description = "收盘价")
-
private BigDecimal closingPrice;
-
-
/** 当前的价格(盘中实时价格) */
-
@Schema(description = "当前的价格")
-
private BigDecimal nowPrice;
-
-
/** 成交量(股) */
-
@Schema(description = "成交量(股)")
-
private long tradingVolume;
-
-
/** 成交量金额(元) */
-
@Schema(description = "成交量金额")
-
private BigDecimal tradingValue;
-
-
/** 涨跌幅度(当前价-昨日收盘价) */
-
@Schema(description = "涨跌幅度")
-
private BigDecimal amplitude;
-
-
/** 涨跌幅度百分比(带%符号,如+2.35%) */
-
@Schema(description = "涨跌幅度百分比")
-
private String amplitudeProportion;
-
-
/** 换手率(%) */
-
@Schema(description = "换手率")
-
private BigDecimal changingProportion;
-
-
/** 量比 */
-
@Schema(description = "量比")
-
private BigDecimal than;
-
-
/** 市盈率(TTM) */
-
@Schema(description = "市盈率")
-
private BigDecimal peRatio;
-
-
/** 是否涨停(1-涨停/0-不涨停,自动计算) */
-
@Schema(description = "是否涨停 1为涨停 0为不涨停")
-
private Integer zt = 0;
-
-
/** 涨跌幅度百分比(double形式,用于计算,如2.35) */
-
@Schema(description = "涨幅 double形式")
-
private Double amplitudeProportionDouble;
-
-
/** 数据源网页地址(辅助信息) */
-
@Schema(description = "webUrl地址信息")
-
private String webUrl;
-
-
/**
-
* 自动计算是否涨停:调用StockUtil工具类判断
-
* 核心逻辑:根据股票代码(区分主板/创业板/科创板)、当前涨幅判断
-
*/
-
public Integer getZt() {
-
return StockUtil.isZt(this.code, this.name, this.amplitudeProportion) ? 1 : 0;
-
}
-
-
/**
-
* 自动转换涨跌幅度百分比为double:去除%符号,转为数值型
-
* 示例:"2.35%" → 2.35,空值返回0.0
-
*/
-
public Double getAmplitudeProportionDouble() {
-
if (StrUtil.isBlank(amplitudeProportion)) {
-
return 0.0d;
-
}
-
String substring = amplitudeProportion.replace("%", "");
-
return Double.parseDouble(substring);
-
}
-
}
关键设计亮点:
- 自动计算字段:通过自定义getter方法实现
zt(涨停状态)和amplitudeProportionDouble(涨跌幅度double值)的自动计算,无需上层代码处理,简化调用; - 字段兼容性:兼顾不同数据源的字段差异(如财联社返回“last_px”表示当前价,腾讯返回“nowPrice”),通过DTO统一封装,屏蔽底层差异;
- 辅助信息字段:
fullCode(全代码)用于数据源接口调用,webUrl用于记录数据源地址,便于问题排查。
二、核心设计:多数据源架构与流程
2.1 多数据源架构设计
为避免单一数据源失效导致功能不可用,设计“主备数据源”架构:以财联社作为主数据源(数据字段更完整,包含市盈率、量比等关键估值数据),腾讯作为备用数据源(接口稳定性高,适合降级使用)。核心架构如下:
上层调用 → 数据源选择器 → 主数据源(财联社)爬取 → 解析封装 → 结果返回;主数据源失败 → 自动切换备用数据源(腾讯) → 解析封装 → 结果返回;均失败 → 返回null+日志记录
2.2 核心流程拆解
无论主备数据源,核心流程均遵循“参数校验 → 构建请求(URL+请求头) → 发送HTTP请求 → 解析响应数据 → DTO封装 → 自动计算字段 → 结果返回”,差异仅在于请求URL、请求头、响应数据格式的解析逻辑。
三、核心实现:多数据源爬取与封装
下面分别拆解财联社(主数据源)和腾讯(备用数据源)的完整实现逻辑,包含请求构建、数据爬取、解析封装等关键步骤,同时说明代码复用要点(如复用第三篇的腾讯历史数据爬取基础方法)。
3.1 主数据源:财联社股票详情爬取实现
财联社提供公开的股票详情接口(URL格式: https://x-quote.cls.cn/quote/stock/basic?secu_code={0}&fields=open_px,av_px,high_px,low_px,change,change_px,down_price,change_3,change_5,qrr,entrust_rate,tr,amp,TotalShares,mc,NetAssetPS,NonRestrictedShares,cmc,business_amount,business_balance,pe,ttm_pe,pb,secu_name,secu_code,trade_status,secu_type,preclose_px,up_price,last_px&app=CailianpressWeb&os=web&sv=8.4.6&sign=9f8797a1f4de66c2370f7a03990d2737),返回JSON格式数据,包含基础信息、行情数据、交易数据、估值数据等全量字段,适合作为主数据源。
3.1.1 核心依赖与工具类
需依赖HTTP工具类(HttpUtil)发送GET请求、JSON工具类(JSONUtil)解析响应数据、 BigDecimal工具类(BigDecimalUtil)处理数值计算,同时需配置财联社接口地址(通过配置文件注入,便于维护)。
3.1.2 完整代码实现
-
@Service
-
public class StockDetailServiceImpl implements StockDetailService {
-
-
@Override
-
public StockShowInfoDto getByCode(String fullCode) {
-
// 1. 参数校验:全代码不能为空(如sz001318、sh600000)
-
if (StrUtil.isBlank(fullCode)) {
-
log.error("财联社爬取股票详情失败:全代码为空");
-
return null;
-
}
-
try {
-
// 2. 构建请求URL:通过MessageFormat格式化占位符
-
String url = MessageFormat.format("https://x-quote.cls.cn/quote/stock/basic?secu_code={0}&fields=open_px,av_px,high_px,low_px,change,change_px,down_price,change_3,change_5,qrr,entrust_rate,tr,amp,TotalShares,mc,NetAssetPS,NonRestrictedShares,cmc,business_amount,business_balance,pe,ttm_pe,pb,secu_name,secu_code,trade_status,secu_type,preclose_px,up_price,last_px&app=CailianpressWeb&os=web&sv=8.4.6&sign=9f8797a1f4de66c2370f7a03990d2737", fullCode);
-
// 3. 构建请求头:模拟浏览器请求,避免被反爬(关键:需设置User-Agent等请求头)
-
Map<String, String> headerMap = buildDjrHeaderMap();
-
// 4. 发送HTTP GET请求:使用无代理的CloseableHttpClient,避免代理失效问题
-
String content = HttpUtil.sendGet(HttpClientConfig.proxyNoUseCloseableHttpClient(), url, headerMap);
-
// 5. 解析响应数据:JSON格式转为JSONObject
-
JSONObject jsonObject = JSONUtil.parseObj(content);
-
// 6. 提取核心数据节点:响应数据中"data"字段为股票详情核心数据
-
JSONObject data = jsonObject.getJSONObject("data");
-
if (data == null) {
-
log.error("财联社爬取股票详情失败:响应数据中data节点为空,fullCode={}", fullCode);
-
return null;
-
}
-
// 7. DTO封装:将JSON数据映射到StockShowInfoDto
-
StockShowInfoDto stockShowInfoDto = new StockShowInfoDto();
-
// 基础信息封装
-
stockShowInfoDto.setCode(StrUtil.subSufByLength(fullCode, 6)); // 从全代码中截取后6位作为股票代码(如sz001318→001318)
-
stockShowInfoDto.setName(data.getStr("secu_name")); // 股票名称
-
stockShowInfoDto.setCurrDate(DateUtil.now()); // 当前查询时间
-
stockShowInfoDto.setFullCode(fullCode); // 全代码(保留原始参数)
-
stockShowInfoDto.setDate(DateUtil.now()); // 行情日期(当日)
-
stockShowInfoDto.setWebUrl("金亥跃江聊量化"); // 辅助信息:数据源标识
-
// 行情数据封装(映射财联社响应字段)
-
stockShowInfoDto.setOpeningPrice(data.getBigDecimal("open_px")); // 开盘价
-
stockShowInfoDto.setYesClosingPrice(data.getBigDecimal("preclose_px")); // 昨日收盘价
-
stockShowInfoDto.setHighestPrice(data.getBigDecimal("high_px")); // 最高价
-
stockShowInfoDto.setLowestPrice(data.getBigDecimal("low_px")); // 最低价
-
stockShowInfoDto.setClosingPrice(data.getBigDecimal("last_px")); // 收盘价(盘中取当前价)
-
stockShowInfoDto.setNowPrice(data.getBigDecimal("last_px")); // 当前价
-
// 交易数据封装
-
stockShowInfoDto.setTradingVolume(data.getLong("business_amount")); // 成交量(股)
-
stockShowInfoDto.setTradingValue(data.getBigDecimal("business_balance")); // 成交金额(元)
-
stockShowInfoDto.setAmplitude(data.getBigDecimal("change_px")); // 涨跌幅度(当前价-昨日收盘价)
-
// 数值计算与格式化:涨跌幅度百分比(乘以100,保留2位小数,加%符号)
-
stockShowInfoDto.setAmplitudeProportion(BigDecimalUtil.mul100(data.getBigDecimal("change")));
-
// 估值与辅助数据封装
-
stockShowInfoDto.setChangingProportion(BigDecimalUtil.mul100B(data.getBigDecimal("tr"))); // 换手率(乘以100,保留2位小数)
-
stockShowInfoDto.setThan(BigDecimalUtil.mul100B(data.getBigDecimal("qrr"))); // 量比(乘以100,保留2位小数)
-
stockShowInfoDto.setPeRatio(data.getBigDecimal("ttm_pe")); // 市盈率(TTM)
-
// 自动计算涨停状态(调用DTO自定义getter方法,无需手动赋值)
-
stockShowInfoDto.setZt(stockShowInfoDto.getZt());
-
-
return stockShowInfoDto;
-
} catch (Exception e) {
-
log.error("财联社爬取股票详情失败,fullCode={}", fullCode, e);
-
// 主数据源失败,可在此处自动切换备用数据源(腾讯),此处简化处理,返回null
-
return null;
-
}
-
}
-
-
/**
-
* 构建财联社请求头:模拟浏览器请求,避免被反爬
-
*/
-
private Map<String, String> buildDjrHeaderMap() {
-
Map<String, String> headerMap = new HashMap<>();
-
headerMap.put("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
-
headerMap.put("Referer", "https://www.cls.cn/");
-
headerMap.put("Accept", "application/json, text/plain, */*");
-
return headerMap;
-
}
-
}
3.1.3 关键注意点
- 请求头构建:必须模拟浏览器请求头(尤其是User-Agent、Referer),否则财联社接口会拒绝返回数据(反爬机制);
- 全代码处理:财联社接口需要传入全代码(如sz001318),需从全代码中截取后6位作为股票纯代码(如001318),便于后续逻辑处理;
- 数值计算:涨跌幅度百分比、换手率、量比等字段需乘以100转为百分比格式,使用BigDecimalUtil工具类避免浮点数精度丢失;
- 异常处理:捕获所有异常(如HTTP请求失败、JSON解析失败、字段缺失等),记录详细日志(包含全代码),便于问题排查,同时为切换备用数据源预留扩展点。
3.2 备用数据源:腾讯股票详情爬取实现
腾讯股票接口(URL格式:http://qt.gtimg.cn/q=sz001318)返回特殊格式字符串(非JSON),需自定义解析逻辑。该接口稳定性高,且无需复杂请求头,适合作为备用数据源。同时,可复用系列第二篇中“腾讯当时数据爬取”的基础方法(parseTxMoneyYesHistory),减少代码冗余。
3.2.1 代码复用要点
复用第三篇中的parseTxMoneyYesHistory方法(用于爬取腾讯股票历史数据),该方法已实现“构建腾讯接口URL、设置请求头、发送HTTP请求、获取响应数据”的核心逻辑,此处仅需新增“响应数据解析为TxStockHistoryInfo列表”和“TxStockHistoryInfo转StockShowInfoDto”的逻辑。
3.2.2 完整代码实现
-
@Service
-
public class StockDetailServiceImpl implements StockDetailService {
-
-
@Resource
-
private DefaultProperties defaultProperties; // 配置文件封装类,包含腾讯接口地址
-
@Resource
-
private DailyTradingInfoParse dailyTradingInfoParse; // 腾讯数据解析工具类
-
-
@Override
-
public StockShowInfoDto getNowInfo(String fullCode) {
-
// 1. 参数校验:全代码不能为空
-
if (StrUtil.isBlank(fullCode)) {
-
log.error("腾讯爬取股票详情失败:全代码为空");
-
return null;
-
}
-
try {
-
// 2. 复用第三篇的基础方法:爬取腾讯股票当前行情数据(返回TxStockHistoryInfo列表)
-
// 注:DateTime.now()表示获取当日数据,parseTxMoneyYesHistory方法已兼容当日实时数据爬取
-
List<TxStockHistoryInfo> txStockHistoryInfos = parseTxMoneyYesHistory(Collections.singletonList(fullCode), DateTime.now());
-
// 3. 校验解析结果:列表为空则返回null
-
if (CollUtil.isEmpty(txStockHistoryInfos)) {
-
log.error("腾讯爬取股票详情失败:解析后数据列表为空,fullCode={}", fullCode);
-
return null;
-
}
-
// 4. 获取单只股票数据(列表仅含一条记录)
-
TxStockHistoryInfo txStockHistoryInfo = txStockHistoryInfos.get(0);
-
// 5. TxStockHistoryInfo转StockShowInfoDto:字段映射+格式转换
-
StockShowInfoDto stockShowInfoDto = new StockShowInfoDto();
-
// 基础信息封装
-
stockShowInfoDto.setCode(txStockHistoryInfo.getCode()); // 股票代码
-
stockShowInfoDto.setName(txStockHistoryInfo.getName()); // 股票名称
-
stockShowInfoDto.setFullCode(fullCode); // 全代码
-
stockShowInfoDto.setExchange(0); // 交易所类型:腾讯接口未返回,暂设为0(可后续扩展解析逻辑)
-
stockShowInfoDto.setDate(DateUtil.now()); // 行情日期(当日)
-
// 行情数据封装(映射TxStockHistoryInfo字段)
-
stockShowInfoDto.setOpeningPrice(txStockHistoryInfo.getOpeningPrice()); // 开盘价
-
stockShowInfoDto.setYesClosingPrice(txStockHistoryInfo.getYesClosingPrice()); // 昨日收盘价
-
stockShowInfoDto.setHighestPrice(txStockHistoryInfo.getHighestPrice()); // 最高价
-
stockShowInfoDto.setLowestPrice(txStockHistoryInfo.getLowestPrice()); // 最低价
-
stockShowInfoDto.setClosingPrice(txStockHistoryInfo.getClosingPrice()); // 收盘价
-
stockShowInfoDto.setNowPrice(txStockHistoryInfo.getNowPrice()); // 当前价
-
// 交易数据封装
-
stockShowInfoDto.setTradingVolume(txStockHistoryInfo.getTradingVolume()); // 成交量(股)
-
stockShowInfoDto.setTradingValue(txStockHistoryInfo.getTradingValue()); // 成交金额(元)
-
stockShowInfoDto.setAmplitude(txStockHistoryInfo.getAmplitude()); // 涨跌幅度
-
// 数值格式化:涨跌幅度百分比转为带%的字符串(如2.35→"2.35%")
-
stockShowInfoDto.setAmplitudeProportion(BigDecimalUtil.toShowString2(txStockHistoryInfo.getAmplitudeProportion()));
-
stockShowInfoDto.setChangingProportion(txStockHistoryInfo.getChangingProportion()); // 换手率(已为百分比格式)
-
stockShowInfoDto.setThan(txStockHistoryInfo.getThan()); // 量比
-
stockShowInfoDto.setPeRatio(txStockHistoryInfo.getDynamicPriceRatio()); // 动态市盈率
-
// 涨停状态:腾讯数据未直接提供,暂设为0(可复用StockUtil工具类计算,此处简化处理)
-
stockShowInfoDto.setZt(0);
-
-
return stockShowInfoDto;
-
} catch (Exception e) {
-
log.error("腾讯爬取股票详情失败,fullCode={}", fullCode, e);
-
return null;
-
}
-
}
-
-
/**
-
* 复用第三篇的腾讯历史数据爬取基础方法:构建URL、发送请求、获取响应数据
-
* 核心逻辑:拼接股票代码列表→构建腾讯接口URL→设置Referer请求头→发送GET请求→返回响应数据解析后的列表
-
*/
-
@Override
-
public List<TxStockHistoryInfo> parseTxMoneyYesHistory(List<String> codeList, DateTime beforeLastWorking) {
-
// 1. 拼接股票代码参数(多个代码用逗号分隔,如"sz001318,sh600000")
-
String qParam = StrUtil.join(",", codeList);
-
// 2. 构建请求URL(配置文件中接口地址格式:http://qt.gtimg.cn/q={0})
-
String url = MessageFormat.format(defaultProperties.getTxMoneyHistoryUrl(), qParam);
-
try {
-
// 3. 构建请求头:仅需设置Referer为腾讯股票页面,避免反爬
-
Map<String, String> header = new HashMap<>();
-
header.put("Referer", "http://qt.gtimg.cn");
-
// 4. 发送HTTP GET请求:使用无代理客户端,编码设为GBK(腾讯接口响应编码为GBK)
-
String content = HttpUtil.sendGet(HttpClientConfig.proxyNoUseCloseableHttpClient(), url, header, "gbk");
-
// 5. 解析响应数据:调用专用解析工具类,将特殊格式字符串解析为TxStockHistoryInfo列表
-
return dailyTradingInfoParse.parseTxMoneyHistory(content, codeList, beforeLastWorking);
-
} catch (Exception e) {
-
log.error("parseTxMoneyYesHistory获取股票{}当前信息失败", qParam, e);
-
return null;
-
}
-
}
-
}
3.2.3 关键注意点
- 代码复用:复用
parseTxMoneyYesHistory方法,减少重复编码,同时保证系统代码风格一致; - 响应编码:腾讯接口响应编码为GBK,发送请求时需指定编码为GBK,否则会出现中文乱码问题;
- 数据格式解析:腾讯接口返回特殊格式字符串(如“v_sz001318=“51~三峡能源001318…“”),需通过
dailyTradingInfoParse.parseTxMoneyHistory工具类自定义解析逻辑(按“~”分割字符串,提取对应字段); - 字段兼容:腾讯数据部分字段缺失(如涨停状态需手动计算),需在封装时补充处理,确保DTO输出格式统一。
3.3 数据源切换策略实现
为实现“主备数据源自动切换”,新增数据源选择器方法,核心逻辑:优先调用财联社接口,若返回null(爬取失败),则自动调用腾讯接口;若均失败,返回null并记录日志。
-
@Service
-
public class StockDetailServiceImpl implements StockDetailService {
-
-
@Override
-
public StockShowInfoDto getStockDetail(String fullCode) {
-
// 1. 优先调用主数据源(财联社)
-
StockShowInfoDto mainSourceResult = getByCode(fullCode);
-
if (mainSourceResult != null) {
-
return mainSourceResult;
-
}
-
// 2. 主数据源失败,切换备用数据源(腾讯)
-
log.warn("财联社爬取失败,切换腾讯数据源,fullCode={}", fullCode);
-
StockShowInfoDto backupSourceResult = getNowInfo(fullCode);
-
if (backupSourceResult != null) {
-
return backupSourceResult;
-
}
-
// 3. 均失败,返回null+记录错误日志
-
log.error("主备数据源爬取均失败,fullCode={}", fullCode);
-
return null;
-
}
-
}
四、核心优化点与生产环境适配
上述实现已满足股票详情获取的基础需求,但在生产环境中需补充以下优化点,确保系统稳定性、性能与可维护性:
-
缓存优化:对股票详情数据添加Redis缓存(缓存key为
STOCK_DETAIL + fullCode),缓存过期时间设为5分钟(行情数据实时性要求较高,不宜过长);爬取新数据后同步更新缓存,减少重复爬取,提升响应速度; -
反爬机制适配:
-
请求频率控制:通过线程池+计数器限制爬取频率(如单IP每分钟最多爬取60次),避免被数据源封禁IP;
-
请求头动态化:维护User-Agent池,每次请求随机选择一个User-Agent,模拟多浏览器请求,降低反爬风险;
-
数据源健康监控:记录主备数据源的爬取成功率(成功次数/总次数),通过监控工具(如Prometheus + Grafana)展示,当主数据源成功率低于90%时,自动发送告警(如钉钉/邮件),便于及时处理;
-
字段校验增强:对DTO封装后的字段进行非空校验(如核心行情字段openingPrice、nowPrice等),若为空则填充默认值(如0.0),避免上层调用出现空指针异常;
-
代码解耦:将HTTP请求、JSON解析、数值计算等通用逻辑抽取为独立工具类(如HttpUtil、JsonUtil、BigDecimalUtil),提升代码复用性与可维护性;
-
降级策略:当主备数据源均失败时,返回最近一次缓存的股票详情数据(若存在),而非直接返回null,提升用户体验。
五、系列文章预告
本文完成了量化系统“数据展示层”的核心模块——股票详细信息实时获取,实现了多数据源爬取、自动切换、数据封装的完整逻辑。下一篇文章将聚焦“构建自己的股票实时查看系统”,结合本文获取的实时详情数据与第四篇的自选股票。
最后,留一个思考问题:在高并发场景下(如大量用户同时查询不同股票详情),如何设计缓存策略(如缓存穿透、缓存击穿、缓存雪崩的解决方案),才能兼顾性能与数据实时性?欢迎在评论区交流~
Java量化系列(六):摸鱼神器!构建专属股票实时价格系统_情绪大师指标源码-CSDN博客
作为量化交易者,既想实时掌握自选股票的价格动态,又不想在工作时间频繁打开行情软件“暴露行踪”?前面我们已经实现了自选股票维护和股票详情获取功能,这一篇就基于这两大基础,打造一个“摸鱼专属”的股票实时价格系统——核心逻辑是:将感兴趣的股票添加到自选列表,通过定时任务在交易时间自动抓取实时价格并更新到Redis,后续只需查看Redis中的数据,就能悄无声息掌握行情,完美适配摸鱼场景!
本文作为Java量化系列的第六篇,将聚焦“实时价格系统的核心构建”,基于Spring定时任务、Redis缓存技术,完整实现“定时调度→批量抓取→缓存存储”的全链路逻辑。提供的代码已涵盖核心业务逻辑,我们会逐模块拆解设计思路、关键细节和优化点,即使是新手也能跟着实现一套属于自己的实时价格系统。
一、核心需求与场景设计
1.1 摸鱼场景核心诉求
不同于专业的行情软件,摸鱼场景下的实时价格系统更注重“低存在感”“高可靠性”“轻量化”,核心需求如下:
- 定时自动更新:交易时间内自动
- 抓取自选股票价格,无需手动触发,避免频繁操作暴露;
- 精准时间调度:仅在股票交易时间(9:30-11:30、13:00-15:00)执行更新,非交易时间不浪费资源;且14:57后需规避尾盘波动,延迟执行避免无效请求;
- 批量高效抓取:支持多只自选股票批量更新,每批控制数量避免触发数据源反爬,同时异步执行提升效率;
- Redis缓存存储:价格数据存储到Redis,查看时直接访问Redis(如通过Redis Desktop Manager工具),无需打开行情软件;缓存有效期合理设置,兼顾实时性与性能;
- 异常兼容:抓取失败时不影响整体系统运行,股票名称缺失时自动补充,保证缓存数据完整性。
1.2 核心流程设计
整个实时价格系统的核心流程可概括为“三步走”,形成闭环:
自选股票录入 → 定时任务触发(交易时间内) → 批量抓取实时价格(调用第五篇的多数据源接口) → Redis缓存更新/存储 → 摸鱼时查看Redis数据
其中,“定时任务触发”“批量抓取价格”“Redis缓存存储”是三大核心模块,也是本文的重点拆解内容。
二、核心设计:定时任务调度策略
定时任务是系统的“心脏”,需精准控制执行时间:仅在工作日的交易时段执行,非交易时间、节假日不执行;14:57后需延迟执行,避免尾盘高频波动导致的无效抓取。这里采用Spring的定时任务注解(@Scheduled)结合Cron表达式实现调度,同时通过时间工具类控制执行边界。
2.1 Cron表达式设计
根据需求,定时任务需在 交易日期内 执行(即每15s执行一次,避开开盘前的9:00-9:30和午休时间),对应的Cron表达式为:
1/15 * 9,10,11,13,14 ? * 1-5
Cron表达式拆解(从左到右):
- 1/15:秒位,每隔15s执行一次
- *:分位,每1分钟执行一次;
- 9,10,11,13,14:时位,仅在9-11点、13-14点执行(避开12点午休);
- ?:日位,不指定具体日期;
- *:月位,所有月份都执行;
- 1-5:星期位,仅周一到周五(工作日)执行。
2.2 定时任务核心代码实现
定时任务核心逻辑:先判断当前是否为交易时间(非交易时间直接返回);若为14:57后(尾盘时段),则休眠4分钟避开波动;最后异步执行自选股票价格更新操作,避免阻塞任务调度。
-
@Component
-
public class StockPriceSchedule {
-
-
@Resource
-
private StockSelectedService stockSelectedService;
-
@Resource
-
private DateHelper dateHelper;
-
// 线程池,用于异步执行价格更新,避免阻塞定时任务
-
@Resource
-
private Executor executor;
-
-
/**
-
* 定时更新自选股票实时价格(摸鱼专属)
-
*/
-
@Scheduled(cron = "1/15 * 9,10,11,13,14 ? * 1-5")
-
@Override
-
public void execute(String param) {
-
// 1. 时间校验:非交易时间直接返回(避免节假日、午休、开盘前/收盘后执行)
-
// dateHelper.isWorkingTime():自定义工具类方法,判断当前是否为股票交易时间(9:30-11:30、13:00-15:00)
-
if (dateHelper.isWorkingTime(new Date())) {
-
return;
-
}
-
-
// 2. 尾盘处理:14:57之后休眠4分钟,避开尾盘波动,确保查询时间在15:00之后
-
if (MyDateUtil.isWillEndStockPriceTime()) {
-
// 休眠240秒(4分钟),使后续价格查询在收盘后执行,避免频繁波动导致的无效数据
-
MyDateUtil.sleep(1000 * 240);
-
}
-
-
// 3. 异步执行价格更新:提交到线程池,避免阻塞定时任务调度(核心:不影响下一次任务执行)
-
executor.execute(
-
() -> {
-
// 更新所有自选股票的价格(param传null表示更新全部)
-
stockSelectedService.updateSelectedCodePrice(null);
-
}
-
);
-
}
-
}
2.3 关键时间工具类说明
上述代码中依赖两个核心时间工具类方法,确保任务执行的时间准确性:
- dateHelper.isWorkingTime():判断当前时间是否为股票交易时间(9:30-11:30、13:00-15:00),非交易时间(如9:00-9:29、12:00-12:59)直接返回,不执行价格更新;
- MyDateUtil.isWillEndStockPriceTime():判断当前时间是否在14:57之后(尾盘时段),若是则休眠4分钟,避免在尾盘高频波动时频繁抓取无效数据;
- MyDateUtil.sleep():自定义休眠方法,封装Thread.sleep(),简化异常处理。
三、核心实现:自选股票价格批量更新
定时任务触发后,核心逻辑是“批量获取自选股票代码→分批抓取价格→异步执行更新”,避免单只股票抓取失败影响整体,同时控制每批数量规避数据源反爬。这部分逻辑主要在StockSelectedService中实现,分为三个核心方法:updateSelectedCodePrice(入口方法)、updateSelectedCodePriceByList(分批处理)、batchGenerateRealPriceData(批量抓取)。
3.1 入口方法:updateSelectedCodePrice
核心作用:接收股票代码参数(可为null),若指定代码则更新单只股票价格,若为null则批量更新所有用户的自选股票价格;同时对股票代码进行分批处理(每5只为一批),避免一次性请求过多触发反爬。
-
@Service
-
public class StockSelectedServiceImpl implements StockSelectedService {
-
-
@Resource
-
private Executor executor;
-
-
@Override
-
public void updateSelectedCodePrice(String code) {
-
// 初始化用于执行价格更新的股票代码列表
-
List<String> executeCodeList;
-
-
// 1. 单只股票更新:若指定了股票代码,直接更新该股票
-
if (StrUtil.isNotBlank(code)) {
-
updateSelectedCodePriceByList(Collections.singletonList(code));
-
} else {
-
// 2. 批量更新:获取所有用户的自选股票代码(findCodeByUserId(-1):-1表示查询所有用户的自选股票)
-
List<String> selectCodeList = findCodeByUserId(-1);
-
// 校验股票代码列表是否为空,为空则直接返回
-
if (CollUtil.isEmpty(selectCodeList)) {
-
return;
-
}
-
-
// 3. 代码列表处理:将查询到的代码添加到待执行列表
-
List<String> codeList = new ArrayList<>();
-
Optional.ofNullable(selectCodeList).ifPresent(codeList::addAll);
-
-
// 4. 分批处理:每5只为一批,避免一次性请求过多触发数据源反爬
-
int batchSize = 5; // 每批数量,可根据数据源反爬策略调整
-
for (int i = 0; i < codeList.size(); i += batchSize) {
-
// 计算当前批次的最后一个索引(避免越界)
-
int maxIndex = Math.min(i + batchSize, codeList.size());
-
// 提取当前批次的股票代码列表
-
executeCodeList = codeList.subList(i, maxIndex);
-
// 异步执行当前批次的价格更新
-
List<String> finalExecuteCodeList = executeCodeList;
-
updateSelectedCodePriceByList(finalExecuteCodeList);
-
}
-
}
-
}
-
}
3.2 分批处理方法:updateSelectedCodePriceByList
核心作用:接收一批股票代码,将价格更新任务提交到线程池异步执行,同时休眠50毫秒避免频繁请求数据源,进一步降低反爬风险。
-
@Override
-
public void updateSelectedCodePriceByList(List<String> codeList) {
-
// 提交异步任务:批量更新一批股票的实时价格
-
List<String> finalExecuteCodeList1 = codeList;
-
executor.submit(
-
() -> {
-
try {
-
// 调用股票抓取服务,批量生成实时价格数据
-
stockCrawlerService.batchGenerateRealPriceData(finalExecuteCodeList1);
-
// 休眠50毫秒:控制请求频率,避免触发数据源反爬机制
-
TimeUnit.MILLISECONDS.sleep(50);
-
} catch (Exception e) {
-
// 异常处理:抓取失败时忽略,避免单批失败影响整体(生产环境可添加日志记录)
-
log.error("批量更新股票价格失败,代码列表:{}", finalExecuteCodeList1, e);
-
}
-
}
-
);
-
}
3.3 批量抓取方法:batchGenerateRealPriceData
核心作用:接收一批股票代码,转换为全代码(如001318→sz001318),调用第五篇实现的多数据源接口(getStockDetail)抓取实时价格,再通过StockCacheService将价格数据缓存到Redis。
StockNowPriceDto 存储缓存对象:
-
@Data
-
@Builder
-
public class StockNowPriceDto implements Serializable {
-
private String code;
-
private String name;
-
private BigDecimal price;
-
private String percent;
-
private String timestamp;
-
-
@Tolerate
-
public StockNowPriceDto() {}
-
-
public String getTimestamp() {
-
return DateUtil.now();
-
}
-
}
-
@Service
-
public class StockCrawlerServiceImpl implements StockCrawlerService {
-
-
@Resource
-
private StockDetailService stockDetailService;
-
@Resource
-
private StockCacheService stockCacheService;
-
@Resource
-
private StockAssember stockAssember; // DTO转换器:StockShowInfoDto → StockNowPriceDto
-
-
// 批量生成股票实时价格数据,并缓存到Redis
-
private void batchGenerateRealPriceData(List<String> codeList) {
-
// 校验代码列表是否为空,为空则直接返回
-
if (CollUtil.isEmpty(codeList)) {
-
return;
-
}
-
-
// 1. 代码格式转换:将纯代码(如001318)转换为全代码(如sz001318、sh600000)
-
// StockUtil.getFullCode():自定义工具类方法,根据股票代码前缀判断交易所(如00开头→深交所sz,60开头→上交所sh)
-
List<String> fullCodeList = codeList.stream().map(
-
StockUtil::getFullCode
-
).collect(Collectors.toList());
-
-
// 2. 遍历代码列表,抓取每只股票的实时价格并缓存
-
for (String code : codeList) {
-
// 调用第五篇实现的多数据源接口,获取股票实时详情(自动切换财联社/腾讯数据源)
-
StockShowInfoDto stockDto = getStockDetail(code);
-
// 转换DTO:将StockShowInfoDto(完整详情)转为StockNowPriceDto(仅价格相关字段,轻量化)
-
StockNowPriceDto nowPriceDto = stockAssember.showInfo2NowPrice(stockDto);
-
// 缓存到Redis:调用StockCacheService的方法
-
stockCacheService.setNowCachePrice(code, nowPriceDto);
-
}
-
}
-
}
3.4 关键说明
- 代码格式转换:StockUtil.getFullCode()是核心工具方法,根据股票代码规则转换为全代码(如00开头→sz,60开头→sh,30开头→sz,68开头→sh),确保能正确调用数据源接口;
- DTO转换:通过StockAssember.showInfo2NowPrice()将完整的StockShowInfoDto转换为轻量化的StockNowPriceDto(仅保留代码、名称、当前价、涨跌幅度等核心字段),减少Redis存储占用;
- 异步执行:从定时任务到分批更新,全程采用异步执行,避免单只股票抓取缓慢或失败影响整体任务进度。
四、核心实现:Redis缓存存储与优化
Redis是摸鱼场景的“核心载体”,所有实时价格数据都存储在这里,查看时只需打开Redis客户端(如Redis Desktop Manager)即可,无需打开行情软件。这部分核心逻辑是“缓存key设计→数据完整性处理→缓存有效期设置”,确保数据可靠且轻量化。
4.1 缓存核心代码实现
-
@Service
-
public class StockCacheServiceImpl implements StockCacheService {
-
-
@Resource
-
private RedisUtil redisUtil;
-
@Resource
-
private StockDomainService stockDomainService;
-
-
@Override
-
public void setNowCachePrice(String code, StockNowPriceDto price) {
-
// 1. 空值校验:若价格DTO为空,直接返回(避免缓存空数据)
-
if (price == null) {
-
return;
-
}
-
-
// 2. 股票名称补充:若名称为空,从股票基础表查询补充(避免缓存中名称缺失)
-
if (!StrUtil.isNotBlank(price.getName())){
-
StockQueryParam stockQueryParam = new StockQueryParam();
-
stockQueryParam.setCode(code);
-
// 从股票基础表(stock表)查询股票信息
-
StockDo stockDo = stockDomainService.getByCondition(stockQueryParam);
-
// 若查询到则补充名称,否则设为“未知股票名”
-
String stockName = Optional.ofNullable(stockDo).map(StockDo::getName).orElse("未知股票名");
-
price.setName(stockName);
-
}
-
-
// 3. 缓存存储:设计合理的key,设置有效期为3天
-
String cacheKey = Const.STOCK_PRICE + code; // 缓存key格式:STOCK_PRICE:001318
-
// 有效期3天:兼顾实时性(交易时间每15s更新一次)和离线查看需求
-
redisUtil.set(cacheKey, price, 3, TimeUnit.DAYS);
-
}
-
}
4.2 关键设计亮点
- 缓存Key设计:采用“固定前缀+股票代码”的格式(如STOCK_PRICE:001318),清晰易识别,后续查看或删除缓存时更便捷;
- 数据完整性处理:若抓取到的价格数据中股票名称为空(如数据源返回异常),自动从股票基础表(stock表)查询补充,避免缓存中出现“未知股票”的无效数据;
- 有效期设置:缓存有效期设为3天,既保证交易时间内每10分钟更新一次的实时性,又支持非交易时间(如周末)离线查看历史数据,适配摸鱼场景的灵活需求;
- 轻量化存储:存储的是StockNowPriceDto(仅核心价格字段),而非完整的StockShowInfoDto,减少Redis内存占用,提升查询速度。
4.3 摸鱼查看方式
缓存存储后,查看方式超简单,全程无需打开行情软件:
- 安装Redis客户端工具(如Redis Desktop Manager、Another Redis DeskTop Manager);
- 连接部署Redis的服务器(本地或云服务器);
- 在Redis中找到对应的缓存Key(如STOCK_PRICE:001318),点击即可查看存储的StockNowPriceDto数据,包含股票名称、当前价、涨跌幅度等核心信息。
小贴士:可将Redis客户端工具设置为“老板键”(如Ctrl+~),工作时一键隐藏,摸鱼查看更安全!
五、核心优化点与生产环境适配
上述实现已满足摸鱼场景的基础需求,但在生产环境中使用或进一步优化时,需补充以下要点:
- 线程池优化:自定义线程池参数(核心线程数、最大线程数、队列容量),避免默认线程池导致的资源耗尽问题;例如核心线程数设为5,最大线程数设为10,队列容量设为100,适配批量更新场景;
- 日志增强:在关键节点(如定时任务触发、批量抓取失败、缓存更新成功)添加详细日志,便于问题排查;例如记录每批更新的股票代码、抓取耗时、缓存Key等信息;
- 失败重试机制:对抓取失败的股票代码,添加重试机制(如最多重试3次,每次间隔1秒),避免因网络波动导致的单次失败;可通过Spring Retry实现重试逻辑;
- Redis集群适配:若部署在生产环境,建议使用Redis集群(主从复制+哨兵模式),避免单点故障导致缓存不可用;同时开启Redis持久化(RDB+AOF),防止数据丢失;
- 代码解耦:将StockUtil、MyDateUtil等工具类抽取为独立模块,提升代码复用性;将Cron表达式、每批处理数量、缓存有效期等配置项放入配置文件(application.yml),便于后续动态调整;
- 权限控制:若多用户使用,需在缓存Key中添加用户ID前缀(如STOCK_PRICE:USER1001:001318),确保不同用户的自选股票数据隔离,避免数据混乱。
六、系列文章预告
本文完成了量化系统“摸鱼专属模块”——股票实时价格系统的构建,实现了定时调度、批量抓取、Redis缓存的全链路逻辑,从此可以悄无声息掌握自选股票动态。下一篇文章将聚焦“股票查询和统计“,进一步提升摸鱼体验!
最后,留一个思考问题:在多用户场景下,如何优化批量更新逻辑(如按用户分组更新、避免重复抓取同一股票),才能兼顾效率与数据隔离?欢迎在评论区交流~
Java量化系列(七):搭建股票统计分析系统,轻松搞定周级涨跌与折线图可视化_java股票分析系统-CSDN博客
在前六篇内容中,我们已经完成了股票数据爬取、自选股管理、实时价格缓存等核心功能,从“能获取数据”逐步升级到“能便捷查看数据”。但对量化交易来说,“看数据”只是基础,“分析数据”才是核心——比如判断一只股票近1周、1个月的涨跌趋势,或是通过一段时间的价格、成交量走势挖掘规律,这些都需要一套完善的统计分析能力支撑。
本文作为Java量化系列的第七篇,将聚焦“股票统计分析系统”的落地实现,核心覆盖两大高频需求:周级别的涨跌统计(快速掌握中短期波动)和折线图数据统计(多维度可视化分析)。我们会结合提供的完整业务代码,从接口设计、逻辑拆解、数据转换、异常处理四个层面,完整还原系统搭建过程,帮你快速掌握股票统计分析的核心技术要点。
一、核心需求与整体设计思路
1.1 统计分析的核心价值
对普通投资者或量化交易者而言,统计分析的核心价值在于“降维复杂数据,提炼关键信息”:
- 周级统计:无需手动对比每日价格,一键获取近1周、2周、3周、1个月的涨跌金额和幅度,快速判断中短期趋势;
- 折线图统计:支持自定义时间范围,生成开盘价、收盘价、成交量等多维度数据,适配ECharts等前端组件实现可视化,直观发现价格波动与成交量的关联规律;
- 灵活适配:支持按需求筛选统计维度(如仅看收盘价和成交量),避免冗余数据,提升分析效率。
1.2 整体技术架构
统计分析系统基于此前搭建的量化基础框架,核心依赖“历史数据服务”(提供股票历史行情数据)和“工具类服务”(处理日期、数值计算),整体架构流程如下:
前端请求(携带股票代码、统计条件)→ 接口参数校验 → 业务层处理(周级计算/折线图数据转换)→ 历史数据服务查询数据 → 数据格式化封装 → 返回标准化结果(适配前端展示)
核心依赖组件:
- 接口层:Spring Boot + Swagger(通过@Operation注解描述接口,方便调试);
- 业务层:自定义StatBusiness,封装周级统计和折线图统计的核心逻辑;
- 数据层:StockHistoryService,提供历史行情数据的查询能力;
- 工具类:DateUtil(日期处理)、BigDecimalUtil(高精度数值计算)、CollUtil(集合处理)。
二、核心实现(一):周级涨跌统计功能
周级统计的核心逻辑是:根据传入的股票代码,查询“最近一个交易日”和“近1周/2周/3周/1个月的最后一个交易日”的收盘价,计算两者的涨跌金额和幅度,最终封装成标准化结果返回。
2.1 接口设计与参数校验
首先设计周级统计接口,采用POST请求,接收包含股票代码的请求对象StockStatRo,核心做两层参数校验:股票代码非空校验、股票是否存在校验,避免无效请求。
-
/**
-
* 获取股票周级别统计信息
-
* @param stockStatRo 包含查询条件的请求对象(核心字段:code-股票代码)
-
* @return 包含股票周级别统计信息的输出结果
-
*/
-
@Operation(summary = "股票周级别统计信息")
-
@PostMapping("/getWeekStat")
-
public OutputResult<StockWeekStatVo> getWeekStat(@RequestBody StockStatRo stockStatRo) {
-
// 第一层校验:股票代码不能为空
-
if (StrUtil.isBlank(stockStatRo.getCode())) {
-
return OutputResult.buildAlert(ResultCode.STOCK_CODE_IS_EMPTY);
-
}
-
// 调用业务层处理,返回结果
-
return statBusiness.getWeekStat(stockStatRo);
-
}
2.2 核心业务逻辑实现
业务层statBusiness的getWeekStat方法是核心,完整实现“股票有效性校验→统计时间点计算→历史数据查询→涨跌金额/幅度计算→结果封装”的全流程。
-
@Override
-
public OutputResult<StockWeekStatVo> getWeekStat(StockStatRo stockStatRo) {
-
// 1. 校验股票是否存在(通过股票代码查询基础信息)
-
StockDto stockDto = stockService.getByCode(stockStatRo.getCode());
-
if (stockDto == null) {
-
return OutputResult.buildAlert(ResultCode.STOCK_CODE_NO_EXIST);
-
}
-
-
// 2. 计算统计时间点:最近1个交易日、近1周、2周、3周、1个月的最后一个交易日
-
int offset = -1; // 偏移量:-1表示“前一天”
-
// 结束时间:当前日期的前一天(确保是交易日,后续通过dateHelper处理)
-
DateTime endDate = DateUtil.date().offsetNew(DateField.DAY_OF_YEAR, offset);
-
List<DateTime> searchDateList = new ArrayList<>();
-
// 统计时间点:近1周(-7天)、近2周(-14天)、近3周(-21天)、近1个月(-1个月)
-
searchDateList.add(DateUtil.date().offsetNew(DateField.DAY_OF_YEAR, offset - 7));
-
searchDateList.add(DateUtil.date().offsetNew(DateField.DAY_OF_YEAR, offset - 14));
-
searchDateList.add(DateUtil.date().offsetNew(DateField.DAY_OF_YEAR, offset - 21));
-
searchDateList.add(DateUtil.date().offsetNew(DateField.MONTH, -1));
-
-
// 3. 初始化返回结果对象
-
StockWeekStatVo stockWeekStatVo = new StockWeekStatVo();
-
List<StockWeekStatInfoVo> weekStatInfoVoList = new ArrayList<>();
-
-
// 4. 查询“最近一个交易日”的历史数据(作为基准值)
-
StockHistoryVo lastVo = stockHistoryService.getLastHistoryVoByCodeAndDate(
-
stockStatRo.getCode(), dateHelper.getBeforeLastWorking(endDate)
-
);
-
-
// 5. 遍历统计时间点,计算每个时间段的涨跌信息
-
int weekIndex = 1; // 用于标记统计类型(1-近1周,2-近2周,3-近3周,4-近1个月)
-
for (DateTime dateTime : searchDateList) {
-
// 查询当前统计时间点的最后一个交易日的历史数据
-
StockHistoryVo tempVo = stockHistoryService.getLastHistoryVoByCodeAndDate(
-
stockStatRo.getCode(), dateHelper.getBeforeLastWorking(dateTime)
-
);
-
-
// 6. 计算涨跌金额和幅度
-
StockWeekStatInfoVo stockWeekStatInfoVo = new StockWeekStatInfoVo();
-
stockWeekStatInfoVo.setType(weekIndex);
-
// 类型名称:通过WeekStatType枚举获取(如1对应“近1周”)
-
stockWeekStatInfoVo.setTypeName(WeekStatType.getExchangeType(weekIndex).getDesc());
-
-
// 涨跌金额 = 最近交易日收盘价 - 统计时间点收盘价(保留4位小数)
-
BigDecimal rangePrice = BigDecimalUtil.sub4(lastVo.getClosingPrice(), tempVo.getClosingPrice());
-
stockWeekStatInfoVo.setRangePrice(BigDecimalUtil.toShowString4(rangePrice));
-
-
// 涨跌幅度 = 涨跌金额 / 统计时间点收盘价 * 100(转成百分比格式)
-
String rangeProportion = BigDecimalUtil.div4Mul100(rangePrice, tempVo.getClosingPrice());
-
stockWeekStatInfoVo.setRangeProportion(rangeProportion);
-
-
weekStatInfoVoList.add(stockWeekStatInfoVo);
-
weekIndex++;
-
}
-
-
// 7. 封装结果并返回
-
stockWeekStatVo.setWeekStatInfoList(weekStatInfoVoList);
-
return OutputResult.buildSucc(stockWeekStatVo);
-
}
2.3 关键设计亮点与注意事项
- 时间处理精准性:通过dateHelper.getBeforeLastWorking()方法获取“指定日期之前的最后一个交易日”,自动过滤周末和节假日,确保统计的是有效交易数据;
- 高精度数值计算:使用BigDecimalUtil工具类的sub4(减法保留4位小数)、div4Mul100(除法后乘100转百分比)方法,避免浮点数精度丢失(股票价格计算核心要求);
- 枚举类复用:通过WeekStatType枚举统一管理统计类型名称(如“近1周”“近1个月”),避免硬编码,后续修改统计维度时更便捷;
- 异常友好处理:股票代码不存在时,返回明确的错误提示(ResultCode.STOCK_CODE_NO_EXIST),便于前端展示和问题排查。
三、核心实现(二):折线图数据统计功能
折线图统计的核心需求是“生成适配前端可视化组件的数据格式”,核心逻辑是:根据传入的股票代码、时间范围、统计维度,查询历史数据并转换为“图例+X轴日期+系列数据”的标准化格式,支持前端直接渲染折线图。
3.1 接口设计与参数说明
折线图统计接口同样采用POST请求,接收的StockStatRo对象包含三个核心参数:code(股票代码)、startDate(开始日期)、endDate(结束日期)、charStockTypeList(统计维度列表,可选)。
-
/**
-
* 获取股票图形统计信息(适配折线图可视化)
-
* @param stockStatRo 包含查询条件的请求对象
-
* 核心字段:code-股票代码、startDate-开始日期、endDate-结束日期、charStockTypeList-统计维度列表
-
* @return 包含图形统计信息的输出对象
-
*/
-
@Operation(summary = "股票图形统计信息")
-
@PostMapping("/getCharStat")
-
public OutputResult<LineVo> getCharStat(@RequestBody StockStatRo stockStatRo) {
-
// 校验股票代码非空
-
if (!StrUtil.isNotBlank(stockStatRo.getCode())) {
-
return OutputResult.buildAlert(ResultCode.STOCK_CODE_IS_EMPTY);
-
}
-
return statBusiness.getCharStat(stockStatRo);
-
}
3.2 核心业务逻辑实现
业务层statBusiness的getCharStat方法分为三个核心步骤:确定统计维度(图例)、计算X轴日期列表、查询历史数据并转换为系列数据。
-
@Override
-
public OutputResult<LineVo> getCharStat(StockStatRo stockStatRo) {
-
// 步骤1:确定统计维度(图例列表)
-
List<String> legendList = new ArrayList<>();
-
// StockCharMoneyType枚举:定义所有支持的统计维度(开盘价、收盘价、成交量等)
-
StockCharMoneyType[] allTypes = StockCharMoneyType.values();
-
-
// 若未指定统计维度,默认返回所有维度;否则按指定维度筛选
-
if (CollUtil.isEmpty(stockStatRo.getCharStockTypeList())) {
-
for (StockCharMoneyType type : allTypes) {
-
legendList.add(type.getDesc()); // 如“开盘价”“收盘价”
-
}
-
} else {
-
for (String typeStr : stockStatRo.getCharStockTypeList()) {
-
// 支持传入维度编码(如1对应开盘价)或直接传入维度名称
-
if (NumberUtil.isInteger(typeStr)) {
-
StockCharMoneyType type = StockCharMoneyType.getTypeByCode(Integer.parseInt(typeStr));
-
if (type != null) {
-
legendList.add(type.getDesc());
-
}
-
} else {
-
legendList.add(typeStr);
-
}
-
}
-
}
-
-
// 步骤2:计算X轴日期列表(仅包含交易日)
-
List<String> xaxisData = new ArrayList<>();
-
String startDate = stockStatRo.getStartDate();
-
String endDate = stockStatRo.getEndDate();
-
// 解析开始日期和结束日期
-
DateTime start = DateUtil.parse(startDate, Const.SIMPLE_DATE_FORMAT);
-
DateTime end = DateUtil.parse(endDate, Const.SIMPLE_DATE_FORMAT);
-
// 获取时间范围内的所有交易日(自动过滤周末、节假日)
-
List<Date> workDayList = dateHelper.betweenWorkDay(start, end);
-
// 格式化日期为字符串(如“2025-12-01”),适配前端展示
-
for (Date date : workDayList) {
-
xaxisData.add(DateUtil.format(date, Const.SIMPLE_DATE_FORMAT));
-
}
-
-
// 步骤3:初始化折线图结果对象
-
LineVo lineVo = new LineVo();
-
lineVo.setLegend(legendList); // 图例
-
lineVo.setXaxisData(xaxisData); // X轴日期
-
-
// 步骤4:查询指定时间范围的股票历史数据
-
List<StockHistoryVo> historyVoList = stockHistoryService.listStockHistoryVoByCodeAndDateRange(
-
stockStatRo.getCode(), start, end
-
);
-
// 若无历史数据,直接返回空系列数据(避免前端报错)
-
if (CollUtil.isEmpty(historyVoList)) {
-
return OutputResult.buildSucc(lineVo);
-
}
-
-
// 步骤5:将历史数据转换为折线图系列数据(适配前端格式)
-
List<LineSeriesVo> seriesList = historyConvertLine(historyVoList, xaxisData.size());
-
// 按统计维度筛选系列数据(只保留用户指定的维度)
-
seriesList = seriesList.stream()
-
.filter(series -> legendList.contains(series.getName()))
-
.collect(Collectors.toList());
-
-
// 步骤6:封装系列数据并返回
-
lineVo.setSeries(seriesList);
-
return OutputResult.buildSucc(lineVo);
-
}
3.3 核心工具方法:历史数据转折线图数据
historyConvertLine方法是折线图数据转换的核心,负责将StockHistoryVo列表(历史行情数据)转换为LineSeriesVo列表(前端可视化需要的系列数据),同时处理“数据长度不足时补0”的问题,确保X轴日期与系列数据一一对应。
-
/**
-
* 将历史数据转换成折线图所需的系列数据
-
* @param stockHistoryVoList 股票历史行情数据列表
-
* @param size X轴日期的长度(确保系列数据长度与之匹配)
-
* @return 折线图系列数据列表
-
*/
-
private List<LineSeriesVo> historyConvertLine(List<StockHistoryVo> stockHistoryVoList, int size) {
-
List<LineSeriesVo> result = new ArrayList<>();
-
-
// 1. 初始化各维度的系列数据对象(对应开盘价、收盘价等)
-
LineSeriesVo openingPrice = new LineSeriesVo();
-
openingPrice.setName(StockCharMoneyType.OPENING_PRICE.getDesc());
-
-
LineSeriesVo closingPrice = new LineSeriesVo();
-
closingPrice.setName(StockCharMoneyType.CLOSING_PRICE.getDesc());
-
-
LineSeriesVo highestPrice = new LineSeriesVo();
-
highestPrice.setName(StockCharMoneyType.HIGHEST_PRICE.getDesc());
-
-
LineSeriesVo lowestPrice = new LineSeriesVo();
-
lowestPrice.setName(StockCharMoneyType.LOWEST_PRICE.getDesc());
-
-
LineSeriesVo amplitudeProportion = new LineSeriesVo();
-
amplitudeProportion.setName(StockCharMoneyType.AMPLITUDE_PROPORTION.getDesc());
-
-
LineSeriesVo tradingVolume = new LineSeriesVo();
-
tradingVolume.setName(StockCharMoneyType.TRADING_VOLUME.getDesc());
-
-
LineSeriesVo changingProportion = new LineSeriesVo();
-
changingProportion.setName(StockCharMoneyType.CHANGING_PROPORTION.getDesc());
-
-
LineSeriesVo than = new LineSeriesVo();
-
than.setName(StockCharMoneyType.THAN.getDesc());
-
-
LineSeriesVo avgPrice = new LineSeriesVo();
-
avgPrice.setName(StockCharMoneyType.AVG_PRICE.getDesc());
-
-
// 2. 遍历历史数据,填充各维度的数值
-
for (StockHistoryVo historyVo : stockHistoryVoList) {
-
openingPrice.getData().add(BigDecimalUtil.toDouble(historyVo.getOpeningPrice()));
-
closingPrice.getData().add(BigDecimalUtil.toDouble(historyVo.getClosingPrice()));
-
highestPrice.getData().add(BigDecimalUtil.toDouble(historyVo.getHighestPrice()));
-
lowestPrice.getData().add(BigDecimalUtil.toDouble(historyVo.getLowestPrice()));
-
amplitudeProportion.getData().add(BigDecimalUtil.toDouble(historyVo.getAmplitudeProportion()));
-
tradingVolume.getData().add(BigDecimalUtil.toDouble(historyVo.getTradingVolume()));
-
changingProportion.getData().add(BigDecimalUtil.toDouble(historyVo.getChangingProportion()));
-
than.getData().add(BigDecimalUtil.toDouble(historyVo.getThan()));
-
avgPrice.getData().add(BigDecimalUtil.toDouble(historyVo.getAvgPrice()));
-
}
-
-
// 3. 数据长度补0:若历史数据长度小于X轴日期长度,前面补0(确保一一对应,前端渲染不错位)
-
if (openingPrice.getData().size() < size) {
-
int lackSize = size - openingPrice.getData().size();
-
List<Double> zeroList = new ArrayList<>(lackSize);
-
for (int i = 0; i < lackSize; i++) {
-
zeroList.add(BigDecimal.ZERO.doubleValue());
-
}
-
// 为所有维度的系列数据补0
-
openingPrice.getData().addAll(0, zeroList);
-
closingPrice.getData().addAll(0, zeroList);
-
highestPrice.getData().addAll(0, zeroList);
-
lowestPrice.getData().addAll(0, zeroList);
-
amplitudeProportion.getData().addAll(0, zeroList);
-
tradingVolume.getData().addAll(0, zeroList);
-
changingProportion.getData().addAll(0, zeroList);
-
than.getData().addAll(0, zeroList);
-
avgPrice.getData().addAll(0, zeroList);
-
}
-
-
// 4. 将所有维度的系列数据添加到结果列表
-
result.add(openingPrice);
-
result.add(closingPrice);
-
result.add(highestPrice);
-
result.add(lowestPrice);
-
result.add(amplitudeProportion);
-
result.add(tradingVolume);
-
result.add(changingProportion);
-
result.add(than);
-
result.add(avgPrice);
-
-
return result;
-
}
3.4 关键设计亮点与注意事项
- 维度灵活筛选:支持用户指定统计维度(如仅看收盘价和成交量),通过stream过滤实现按需返回,减少数据传输量;
- 数据对齐处理:历史数据长度不足时(如时间范围内包含非交易日),自动在前面补0,确保X轴日期与系列数据一一对应,避免前端折线图渲染错位;
- 标准化输出格式:返回的LineVo对象包含“图例、X轴日期、系列数据”,完全适配ECharts等前端可视化组件的要求,前端可直接使用,无需额外处理;
- 兼容多种传入格式:统计维度支持传入编码(如1)或名称(如“开盘价”),提升接口的易用性。
四、核心优化点与生产环境适配
上述实现已满足基础的统计分析需求,若要在生产环境使用,需补充以下优化点:
- 缓存优化:对周级统计结果和折线图数据添加Redis缓存(缓存key包含股票代码、时间范围、统计维度),缓存有效期设为1小时(行情数据短期变化不大),减少数据库查询压力;
- 限流保护:通过Spring Cloud Gateway或自定义拦截器,对统计接口进行限流(如单IP每分钟最多10次请求),避免恶意请求导致系统过载;
- 数据权限控制:若支持多用户使用,需在接口中添加用户ID参数,仅允许用户查询自己关注的自选股统计数据,确保数据隔离;
- 日志增强:在关键节点(如数据查询失败、维度转换异常)添加详细日志,记录股票代码、时间范围等信息,便于问题排查;
- 前端适配示例:提供简单的ECharts渲染示例,帮助前端快速对接接口(如折线图渲染收盘价和成交量的双Y轴图表)。
五、系列文章预告
本文完成了量化系统的“统计分析层”搭建,实现了周级涨跌统计和折线图数据统计两大核心功能,从此我们不仅能获取股票数据,还能通过统计分析挖掘数据规律。下一篇文章将聚焦“ 推送每日自选股票数据到个人邮箱“
最后,留一个思考问题:在处理大量股票的统计请求时,如何通过异步处理或批量处理提升系统的并发能力?欢迎在评论区交流你的想法~
Java量化系列(八):实现自选股最近10天行情涨跌邮件推送_取得某个股票前10天的数据-CSDN博客
在前七篇内容中,我们已经搭建了股票数据爬取、自选股管理、统计分析等核心功能,从“获取数据”“分析数据”升级到“可视化数据”。但对忙碌的投资者来说,每天主动打开系统查看行情仍有门槛——有没有办法让关键信息“主动找上门”?答案是肯定的!
本文作为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 → 调用邮件服务发送 → 发送结果日志记录
核心依赖组件:
- 定时调度: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);
}
三、核心实现(二):自选股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()
));
}
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());
}
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;
}
四、核心实现(三):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换行)
}
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;
}
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>
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();
}
五、核心实现(四):邮件发送功能实现
邮件发送是最终环节,基于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;
}
}
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
六、核心优化点与生产环境适配
上述实现已满足基础的邮件推送需求,若要在生产环境使用,需补充以下优化点:
- 多用户支持:当前以单用户为例,生产环境可查询用户表获取所有开通推送服务的用户,通过线程池异步处理多用户推送,提升效率;
- 失败重试机制:邮件发送失败时添加重试逻辑(如最多重试3次,每次间隔5秒),可通过Spring Retry实现;
- 模板缓存优化:Velocity模板可添加缓存,避免每次推送都重新加载模板文件,提升渲染速度;
- 配置化管理:将Cron表达式、分页大小、模板路径等参数放入配置文件,后续调整无需修改代码;
- 监控告警:添加推送结果监控,若连续多次推送失败,触发短信/钉钉告警,确保问题及时发现;
- 附件支持:可扩展添加“Excel报表附件”功能,用户可下载自选股涨跌数据进行离线分析。
七、系列文章预告
本文完成了量化系统的“信息触达层”搭建,实现了自选股涨跌数据的自动化邮件推送,从此关键行情信息不再遗漏。下一篇文章将聚焦“系统监控与异常处理”,搭建全面的监控体系:包括任务执行状态监控、数据抓取失败告警、服务器资源监控等,确保量化系统稳定运行!
最后,留一个思考问题:在多用户场景下,如何避免大量用户同时推送导致的邮件服务器限流?欢迎在评论区交流你的解决方案~
Java量化系列(九):实现股票列表自动同步,精准监控新增、更名与退市动态-CSDN博客
在前八篇内容中,我们已经搭建了股票数据爬取、自选股管理、统计分析、邮件推送等核心功能,形成了“数据获取-分析-触达”的完整闭环。但量化系统的基础是“数据准确性”——股票市场每天都可能有新股上市、老股更名或退市,如果数据库中的股票列表无法及时同步,会导致后续爬取、统计功能出现异常(比如爬取已退市股票数据失败,或遗漏新股行情)。
本文作为Java量化系列的第九篇,将聚焦“股票列表自动同步与变更监控”功能的落地实现:核心是工作日9:26自动对比“最新网络股票列表”与“数据库存量列表”,精准识别新增上市、名称变更、股票退市三类动态,同步更新数据库并记录变更日志,为整个量化系统的稳定运行筑牢数据基础。
一、核心需求与整体设计思路
1.1 功能核心价值
股票列表同步监控功能的核心是“保障数据时效性与准确性”,精准解决三类痛点:
- 避免遗漏新股:自动捕获新上市股票,及时纳入系统监控范围,不错过新股行情;
- 跟踪名称变更:部分股票会因重组、业务调整等原因更名(如“XX科技”更名为“XX智能”),自动同步避免后续查询、统计出现偏差;
- 识别股票退市:及时标记已退市股票状态,避免系统持续爬取无效数据,减少资源浪费;
- 完整日志追溯:所有变更动态(新增/更名/退市)均记录日志,支持后续查询历史变更记录,便于问题排查。
1.2 核心需求拆解
结合股票市场运行规律(A股9:30开盘),明确四大核心需求:
- 定时调度:按Cron表达式
1 26 9 ? * 1-5执行,即工作日9:26:01触发同步任务(开盘前4分钟完成,不影响当日行情爬取); - 数据对比:从网络爬虫获取最新股票列表,与数据库存量列表对比,识别三类状态:新增(网络有、数据库无)、更名(编码相同、名称不同)、退市(数据库有、网络无);
- 数据同步:批量新增新股、更新更名股票信息、标记退市股票状态;
- 日志记录:将所有变更信息写入
stock_update_log表,包含股票编码、变更类型、变更时间等核心字段,支持追溯。
1.3 整体技术架构
本功能基于此前的量化框架扩展,核心依赖“定时调度组件”“爬虫服务”“股票数据服务”“日志记录服务”四大模块,整体流程如下:
定时任务触发(9:26 工作日)→ 数据库查询存量股票列表 → 爬虫获取最新股票列表 → 数据对比(识别新增/更名/退市)→ 批量同步数据(新增/更新/标记退市)→ 记录变更日志 → 清除缓存(可选,确保查询实时性)
核心依赖组件:
- 定时调度:Spring Scheduler(基于Cron表达式实现精准定时);
- 数据获取:CrawlerService(爬虫获取最新股票列表)、StockDomainService(数据库股票数据查询与同步);
- 数据处理:Stream API(列表转Map,提升对比效率)、DateUtil(时间处理);
- 日志存储:StockUpdateLogDo(变更日志实体)、stock_update_log表(日志存储表)。
二、核心实现(一):定时调度配置与核心入口
定时调度是功能自动化的核心,关键在于Cron表达式的精准配置和任务入口逻辑的设计,确保同步任务在开盘前完成。
2.1 Cron表达式解析与配置
本次需求的Cron表达式为:1 26 9 ? * 1-5,从左到右逐位解析:
- 1:秒位,精确到1秒触发,避免因任务重叠导致重复执行;
- 26:分位,即26分触发;
- 9:时位,即9点触发;
- ?:日位,不指定具体日期(因星期位已指定,日位用?占位);
- *:月位,所有月份都执行;
- 1-5:星期位,仅周一到周五(工作日)执行,避开周末和节假日。
在Spring Boot中,只需在任务方法上添加@Scheduled(cron = "1 26 9 ? * 1-5")注解即可启用定时调度(需确保启动类添加@EnableScheduling注解)。同时建议添加@Async注解(需开启异步支持),避免同步任务阻塞其他核心业务。
2.2 同步任务核心入口逻辑
任务入口方法负责串联“数据获取→数据对比→数据同步→日志记录”全流程,核心逻辑清晰,步骤化处理确保可维护性。
/**
* 工作日9:26自动同步股票列表,识别新增、更名、退市动态
*/
@Async
@Scheduled(cron = "1 26 9 ? * 1-5")
public void autoSyncStockList() {
log.info("=== 股票列表自动同步任务开始执行 ===");
long startTime = System.currentTimeMillis();
try {
// 步骤2:从数据库查询存量股票列表,转换为Map(以股票编码为键,提升对比效率)
List<StockDo> dbAllStockList = stockDomainService.list();
log.info(">>> 数据库查询所有股票记录成功,查询条数:{}", dbAllStockList.size());
Map<String, StockDo> dbStockCodeMap = dbAllStockList.stream()
.collect(Collectors.toMap(StockDo::getCode, stockDo -> stockDo));
// 步骤3:从网络爬虫获取最新股票列表(核心依赖爬虫服务,需确保爬虫稳定)
List<DownloadStockInfo> webStockList = crawlerService.getStockList();
log.info(">>> 爬虫获取最新股票列表成功,查询条数:{}", webStockList.size());
// 转换为Map,后续用于识别退市股票(数据库有、网络无)
Map<String, DownloadStockInfo> webStockCodeMap = webStockList.stream()
.collect(Collectors.toMap(DownloadStockInfo::getCode, info -> info));
// 步骤4:初始化存储容器(新增/更新股票列表、变更日志列表)
List<StockDo> addStockDoList = new ArrayList<>();
List<StockDo> updateStockDoList = new ArrayList<>();
List<StockUpdateLogDo> stockUpdateLogList = new ArrayList<>();
Date now = DateUtil.date(); // 当前时间,用于设置更新时间
// 步骤5:遍历网络股票列表,识别新增和更名股票
for (DownloadStockInfo webStock : webStockList) {
String stockCode = webStock.getCode();
if (!dbStockCodeMap.containsKey(stockCode)) {
// 情况1:数据库无该股票 → 新增股票
handleAddStock(webStock, now, addStockDoList, stockUpdateLogList);
} else {
// 情况2:数据库有该股票 → 校验是否更名
handleUpdateStock(webStock, dbStockCodeMap.get(stockCode), now, updateStockDoList, stockUpdateLogList);
}
}
// 步骤6:遍历数据库股票列表,识别退市股票(网络无、数据库有)
handleDelistedStock(dbAllStockList, webStockCodeMap, now, updateStockDoList, stockUpdateLogList);
// 步骤7:批量同步数据到数据库(新增/更新股票信息、插入变更日志)
batchSyncData(addStockDoList, updateStockDoList, stockUpdateLogList);
long endTime = System.currentTimeMillis();
log.info("=== 股票列表自动同步任务执行完成,耗时:{}ms ===", (endTime - startTime));
} catch (Exception e) {
log.error("=== 股票列表自动同步任务执行失败 ===", e);
// 可选:发送告警通知(钉钉/邮件),及时发现问题
notifyAdmin("股票列表同步任务失败", e.getMessage());
}
}
三、核心实现(二):数据对比核心逻辑(新增/更名/退市识别)
数据对比是功能的核心,需分别处理“新增”“更名”“退市”三类场景,确保每类变更都能被精准识别并记录。
3.1 处理新增股票:handleAddStock
当网络股票列表中存在、数据库中不存在该股票时,判定为“新增上市”,需构建StockDo对象加入新增列表,同时记录变更日志(更新类型为1)。
/**
* 处理新增股票(网络有、数据库无)
* @param webStock 网络获取的新增股票信息
* @param now 当前时间
* @param addStockDoList 新增股票存储列表
* @param stockUpdateLogList 变更日志存储列表
*/
private void handleAddStock(DownloadStockInfo webStock, Date now,
List<StockDo> addStockDoList, List<StockUpdateLogDo> stockUpdateLogList) {
// 1. 转换为数据库StockDo实体(通过Assembler工具类,统一对象转换规则)
StockDo addStockDo = stockAssembler.downInfoToDO(webStock);
// 2. 设置基础信息(创建时间、创建人、可用状态、数据标识)
addStockDo.setCreateTime(now);
addStockDo.setCreateUser("stock_sync_job"); // 标记为同步任务创建
addStockDo.setUpdateTime(now);
addStockDo.setUpdateUser("stock_sync_job");
addStockDo.setCanUse(1); // 新增股票默认可用(1=可用,0=不可用)
addStockDo.setFlag(DataFlagType.NORMAL.getCode()); // 正常数据标识(未删除)
// 3. 加入新增列表
addStockDoList.add(addStockDo);
log.info(">>> 识别新增股票:编码={},名称={},交易所类型={}",
webStock.getCode(), webStock.getName(), webStock.getExchange());
// 4. 记录新增日志(更新类型1=新上市)
StockUpdateLogDo addLog = buildStockUpdateLog(webStock, now, 1, null);
stockUpdateLogList.add(addLog);
}
3.2 处理更名股票:handleUpdateStock
当网络与数据库中存在同一编码股票,但名称不一致时,判定为“名称修改”,需更新数据库股票名称,同时记录变更日志(更新类型为2)。
/**
* 处理更名股票(编码相同、名称不同)
* @param webStock 网络获取的最新股票信息
* @param dbStock 数据库存量股票信息
* @param now 当前时间
* @param updateStockDoList 更新股票存储列表
* @param stockUpdateLogList 变更日志存储列表
*/
private void handleUpdateStock(DownloadStockInfo webStock, StockDo dbStock, Date now,
List<StockDo> updateStockDoList, List<StockUpdateLogDo> stockUpdateLogList) {
String oldName = dbStock.getName();
String newName = webStock.getName();
// 校验:名称不同或可用状态变更时,才执行更新(避免无效更新)
if (!oldName.equals(newName) || !dbStock.getCanUse().equals(webStock.getCanUse())) {
// 1. 更新股票名称和可用状态
dbStock.setName(newName);
dbStock.setCanUse(webStock.getCanUse());
dbStock.setUpdateTime(now);
dbStock.setUpdateUser("stock_sync_job");
// 2. 加入更新列表
updateStockDoList.add(dbStock);
log.info(">>> 识别更名股票:编码={},旧名称={},新名称={}",
webStock.getCode(), oldName, newName);
// 3. 记录更名日志(更新类型2=名称修改)
StockUpdateLogDo updateLog = buildStockUpdateLog(webStock, now, 2, oldName);
stockUpdateLogList.add(updateLog);
// 可选:针对特殊股票类型发送通知(如新股N开头、ST股)
sendSpecialStockNotify(webStock, oldName);
}
}
3.3 处理退市股票:handleDelistedStock
当数据库中存在、网络股票列表中不存在该股票时,判定为“退市”,需将数据库股票可用状态标记为0(不可用),同时记录变更日志(更新类型为3)。
/**
* 处理退市股票(数据库有、网络无)
* @param dbAllStockList 数据库存量股票列表
* @param webStockCodeMap 网络股票编码Map
* @param now 当前时间
* @param updateStockDoList 更新股票存储列表
* @param stockUpdateLogList 变更日志存储列表
*/
private void handleDelistedStock(List<StockDo> dbAllStockList, Map<String, DownloadStockInfo> webStockCodeMap,
Date now, List<StockDo> updateStockDoList, List<StockUpdateLogDo> stockUpdateLogList) {
for (StockDo dbStock : dbAllStockList) {
String stockCode = dbStock.getCode();
if (!webStockCodeMap.containsKey(stockCode) && dbStock.getCanUse().equals(1)) {
// 1. 标记为不可用(0=不可用),避免后续爬取和统计
dbStock.setCanUse(0);
dbStock.setUpdateTime(now);
dbStock.setUpdateUser("stock_sync_job");
// 2. 加入更新列表
updateStockDoList.add(dbStock);
log.info(">>> 识别退市股票:编码={},名称={}", stockCode, dbStock.getName());
// 3. 记录退市日志(更新类型3=退市)
DownloadStockInfo delistedStock = new DownloadStockInfo();
delistedStock.setCode(stockCode);
delistedStock.setName(dbStock.getName());
delistedStock.setExchange(dbStock.getExchange());
delistedStock.setFullCode(dbStock.getFullCode());
StockUpdateLogDo delistLog = buildStockUpdateLog(delistedStock, now, 3, null);
stockUpdateLogList.add(delistLog);
}
}
}
3.4 统一构建变更日志:buildStockUpdateLog
抽取通用方法构建变更日志,确保日志格式统一,包含股票编码、名称、交易所类型、变更时间、变更类型等核心字段,便于后续追溯。
/**
* 统一构建股票变更日志
* @param webStock 股票基础信息(新增/更名/退市)
* @param now 变更时间
* @param updateType 变更类型(1=新增,2=更名,3=退市)
* @param oldName 旧名称(仅更名时非空)
* @return 股票变更日志实体
*/
private StockUpdateLogDo buildStockUpdateLog(DownloadStockInfo webStock, Date now, int updateType, String oldName) {
StockUpdateLogDo updateLog = new StockUpdateLogDo();
updateLog.setCode(webStock.getCode());
updateLog.setName(webStock.getName()); // 新增/退市时为当前名称,更名时为新名称
updateLog.setExchange(webStock.getExchange()); // 交易所类型(0=深圳,1=上海,2=北京)
updateLog.setFullCode(webStock.getFullCode()); // 股票全编码(如600000.SH)
updateLog.setUpdateTime(now);
updateLog.setUpdateType(updateType); // 变更类型
// 可选:日志中记录旧名称(仅更名场景)
if (updateType == 2 && StrUtil.isNotBlank(oldName)) {
updateLog.setName(oldName + "→" + webStock.getName()); // 格式:旧名称→新名称
}
return updateLog;
}
四、核心实现(三):数据批量同步与数据库设计
数据同步是功能落地的关键,采用批量操作提升效率;同时合理设计数据库表结构,确保变更日志的完整存储。
4.1 批量同步数据:batchSyncData
批量新增、更新股票信息和插入变更日志,减少数据库交互次数,提升同步效率(尤其是股票数量较多时)。
/**
* 批量同步数据到数据库(新增/更新股票、插入变更日志)
* @param addStockDoList 新增股票列表
* @param updateStockDoList 更新股票列表(更名/退市)
* @param stockUpdateLogList 变更日志列表
*/
private void batchSyncData(List<StockDo> addStockDoList, List<StockDo> updateStockDoList,
List<StockUpdateLogDo> stockUpdateLogList) {
// 1. 批量新增股票(存在新增数据时执行)
if (CollUtil.isNotEmpty(addStockDoList)) {
boolean addSuccess = stockDomainService.saveBatch(addStockDoList, 100); // 每100条批量插入
log.info(">>> 批量新增股票{},新增条数:{}", addSuccess ? "成功" : "失败", addStockDoList.size());
}
// 2. 批量更新股票(存在更新数据时执行)
if (CollUtil.isNotEmpty(updateStockDoList)) {
boolean updateSuccess = stockDomainService.updateBatchById(updateStockDoList, 100); // 每100条批量更新
log.info(">>> 批量更新股票{},更新条数:{}", updateSuccess ? "成功" : "失败", updateStockDoList.size());
}
// 3. 批量插入变更日志(存在日志数据时执行)
if (CollUtil.isNotEmpty(stockUpdateLogList)) {
boolean logSuccess = stockUpdateLogDomainService.saveBatch(stockUpdateLogList, 100); // 每100条批量插入
log.info(">>> 批量插入变更日志{},日志条数:{}", logSuccess ? "成功" : "失败", stockUpdateLogList.size());
}
}
4.2 变更日志表设计:stock_update_log
专门设计股票变更日志表,记录所有股票状态变更,支持历史追溯和数据审计,表结构如下(对应提供的SQL):
CREATE TABLE `stock_update_log` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`code` varchar(6) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL COMMENT '股票编码',
`name` varchar(200) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '股票名称(更名时格式:旧名称→新名称)',
`update_time` timestamp NULL DEFAULT NULL COMMENT '更新时间',
`update_type` int DEFAULT NULL COMMENT '更新类型 1为新上市 2为名称修改 3退市',
`exchange` int DEFAULT NULL COMMENT '交易所类型(0=深圳,1=上海,2=北京)',
`full_code` varchar(100) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '股票全编码(如600000.SH)',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_stock_update_log_1` (`update_time`) USING BTREE -- 按更新时间建索引,提升查询效率
) ENGINE=InnoDB AUTO_INCREMENT=92652 DEFAULT CHARSET=utf8mb3 ROW_FORMAT=DYNAMIC COMMENT='股票修改记录表';
对应的实体类StockUpdateLogDo(提供的代码):采用MyBatis-Plus注解映射数据库表,字段与表结构一一对应,确保数据存储准确。
五、核心实现(四):特殊场景处理与优化
5.1 特殊股票类型通知:sendSpecialStockNotify
针对特殊股票类型(如新股N开头、ST股),可扩展通知功能,通过邮件/钉钉推送提醒,帮助用户及时关注重点股票动态。
/**
* 特殊股票类型通知(可选功能)
* @param webStock 股票信息
* @param oldName 旧名称(更名场景)
*/
private void sendSpecialStockNotify(DownloadStockInfo webStock, String oldName) {
String stockCode = webStock.getCode();
String stockName = webStock.getName();
// 1. 新股通知(名称以"N"开头,A股新股上市首日名称带"N")
if (stockName.startsWith("N")) {
String content = String.format("【新股上市通知】新增股票:编码=%s,名称=%s,交易所类型=%s",
stockCode, stockName, getExchangeDesc(webStock.getExchange()));
notifyService.sendDingTalkNotify(content); // 发送钉钉通知给管理员/用户
}
// 2. ST股通知(名称以"ST"开头,提示风险)
else if (stockName.startsWith("ST") && !oldName.startsWith("ST")) {
String content = String.format("【股票风险提示】股票%s(%s)变更为ST股,请注意风险!",
stockCode, stockName);
notifyService.sendDingTalkNotify(content);
}
}
六、核心优化点与生产环境适配
上述实现已满足基础的股票同步需求,若要在生产环境使用,需补充以下优化点:
- 爬虫稳定性保障:爬虫服务添加失败重试机制(如最多重试3次,每次间隔3秒),避免因网络波动导致获取最新股票列表失败;
- 分布式锁控制:若系统部署在多节点,需添加分布式锁(如Redis分布式锁),避免多节点同时执行同步任务导致数据重复;
- 数据校验增强:对爬虫获取的股票数据进行校验(如编码长度、交易所类型合法性),避免脏数据入库;
- 监控告警完善:添加任务执行状态监控(如执行耗时、新增/更新/退市股票数量),异常时触发钉钉/邮件告警;
- 日志归档策略:stock_update_log表数据量会随时间增长,需制定归档策略(如按月归档历史日志到备份表),提升查询效率。
七、系列文章预告
本文完成了量化系统的“数据基础层”优化,实现了股票列表的自动同步与变更监控,确保了后续所有业务(爬取、统计、推送)的数据准确性。下一篇文章将聚焦 “指数的实时数据和保存”,对常见的指数进行处理。
Java量化系列(十):10s一次实时抓取!打造专属大盘指数监控神器,精准把握市场脉搏-CSDN博客
炒股的朋友都懂一个道理:看盘先看势,顺势而为才能少踩坑。这里的“势”,核心就是大盘指数的动态——上证指数定方向,深证成指看成长,创业板指察情绪,沪深300判权重。但市面上的行情软件要么广告多,要么数据延迟,想实时掌握核心指数动态总差口气。
本文作为Java量化系列的第十篇,将带大家打造专属大盘指数实时监控神器:核心是工作日交易时段(9:00-11:00、13:00-14:00)每10秒触发一次抓取(Cron表达式精准控制),同步东方财富/财联社双数据源的指数数据,实时缓存到Redis,让你毫秒级获取最新价、涨跌幅等关键信息,为交易决策筑牢数据基础!
一、为什么要做专属指数实时监控?3大痛点直击股民刚需
可能有朋友会问:“市面上行情软件这么多,为啥还要自己造轮子?” 其实做专属监控,正是为了解决商用软件的3大核心痛点:
- 数据延迟高:免费行情软件普遍有3-15秒延迟,短线交易中,这几秒可能就是盈利与亏损的差距;
- 广告干扰多:打开软件全是开户、投顾广告,想快速看个指数还要层层跳转;
- 定制化不足:无法根据自己的交易策略自定义指数预警(比如上证指数跌破3900触发提醒),也不能对接自己的量化系统。
而我们打造的这套监控系统,不仅能实现10秒级实时抓取,还能无缝对接前九篇搭建的量化框架,后续可轻松扩展指数预警、策略触发等高级功能,真正做到“数据为自己所用”。
二、核心需求与技术选型:稳定、高效、低延迟
2.1 核心需求拆解
- 定时抓取:工作日9:00-11:00、13:00-14:00时段,每10秒抓取一次核心指数数据(Cron:1/10 * 9,10,11,13,14 ? * 1-5);
- 多源保障:支持东方财富、财联社双数据源抓取,避免单一数据源故障导致监控中断;
- 数据完整:获取上证指数、深证成指、创业板指、沪深300等核心指数的最新价、涨跌幅、涨跌额等关键信息;
- 实时缓存:抓取的数据实时存入Redis,保证后续查询、使用的毫秒级响应;
- 异常兼容:单个数据源抓取失败时不影响整体功能,自动返回空列表避免程序崩溃。
2.2 核心技术选型
结合需求特点,选型聚焦“高效、稳定、低延迟”三大核心:
- 定时调度:Spring Scheduler,轻量高效,支持精准Cron表达式配置,无需额外部署中间件;
- 数据抓取:HttpClient,自定义请求头模拟浏览器访问,避免被目标网站拦截;
- 数据解析:FastJSON,高效解析JSON格式的响应数据,适配东方财富、财联社的返回结构;
- 缓存存储:Redis,内存级存储,支持key-value快速读写,完美适配实时数据的存储需求;
- 多源切换:通过接口封装实现数据源动态切换,东方财富为主、财联社为备,提升系统稳定性。
三、核心实现(一):定时调度与主流程设计
整个系统的核心入口是定时任务,通过精准的Cron配置控制抓取时机,再串联“数据源选择→数据抓取→数据筛选→缓存存储”全流程。
3.1 Cron表达式解析
本次使用的Cron表达式:1/10 * 9,10,11,13,14 ? * 1-5,逐位拆解核心逻辑:
- 1/10:从第1秒开始,每10秒执行一次(比如1秒、11秒、21秒…触发);
- *:任意分钟都执行;
- 9,10,11,13,14:仅在9点、10点、11点、13点、14点这几个小时执行;
- ?:日位不指定(因星期位已限定,避免冲突);
- *:任意月份都执行;
- 1-5:仅周一到周五(工作日)执行,避开周末休市。
3.2 主流程核心代码
主流程方法updateStockIndexPrice负责串联全流程,支持通过type参数筛选特定类型的指数(如仅抓取上证指数),灵活适配不同使用场景:
/**
* 定时更新股票指数价格(核心主流程)
* @param type 指数类型:1=上证,2=沪深,3=创业板,4=沪深300(null则抓取全部)
*/
@Override
public void updateStockIndexPrice(Integer type) {
// 1. 选择数据源抓取指数数据(优先东方财富,可按需切换财联社)
List<StockIndexInfo> stockIndexList = extCrawlerService.findStockIndex();
// 备用:财联社数据源(单一数据源故障时启用)
// List<StockIndexInfo> stockIndexList = crawlerDrjService.findStockIndex();
// 2. 按类型筛选指数(如仅需上证指数,传入type=1)
if (type != null) {
stockIndexList = stockIndexList.stream()
.filter(index -> type.equals(index.getType()))
.collect(Collectors.toList());
}
// 3. 数据为空则直接返回,避免无效操作
if (CollUtil.isEmpty(stockIndexList)) {
log.info("本次抓取未获取到有效指数数据");
return;
}
// 4. 遍历数据,存入Redis缓存
for (StockIndexInfo stockIndexInfo : stockIndexList) {
stockCacheService.updateStockIndex(stockIndexInfo);
}
log.info("指数数据更新完成,共更新{}条数据", stockIndexList.size());
}
四、核心实现(二):多数据源抓取与数据解析
数据抓取是系统的核心能力,我们分别实现东方财富和财联社的抓取逻辑,通过统一接口返回数据,保证后续流程的兼容性。
4.1 东方财富数据源(主力推荐)
东方财富的指数接口返回数据完整、稳定性高,是我们的主力数据源。核心逻辑是“构造请求URL→发送GET请求→解析JSON响应→封装实体类”。
4.1.1 抓取核心代码
/**
* 东方财富指数数据抓取
* @return 核心指数列表
*/
@Override
public List<StockIndexInfo> findStockIndex() {
// 1. 构造请求URL(通过配置文件注入基础URL,动态拼接回调参数)
String url = "https://push2.eastmoney.com/api/qt/clist/get?pi=0&pz=100&po=1&np=1&fields=f1,f2,f3,f4,f12,f13,f14&fltt=2&invt=2&ut=433fd2d0e98eaf36ad3d5001f088614d&fs=i:1.000001,i:0.399001,i:0.399006,i:1.000300&cb=jQuery112405795797323925824_1676954612820&_=";
try {
// 2. 发送GET请求(自定义请求头,模拟浏览器访问)
String content = HttpUtil.sendGet(
HttpClientConfig.proxyNoUseCloseableHttpClient(),
url + MyDateUtil.getTimezone(), // 拼接时间戳,避免缓存
buildDfHeaderMap() // 构造东方财富专属请求头
);
// 3. 清洗响应数据(去除jQuery回调包裹,保留纯JSON)
content = content.substring("jQuery112405795797323925824_1676954612820".length() + 1);
content = content.substring(0, content.length() - 2);
// 4. 解析JSON数据,封装为StockIndexInfo列表
return stockInfoParser.parseStockIndex(content);
} catch (Exception e) {
log.error("东方财富指数抓取失败,URL:{}", url, e);
return Collections.emptyList();
}
}
4.1.2 响应数据解析
东方财富的响应是jQuery回调包裹的JSON,解析时需先清洗数据,再提取data.diff中的指数列表,核心解析代码如下:
/**
* 解析东方财富指数JSON数据
* @param content 清洗后的纯JSON字符串
* @return 封装后的StockIndexInfo列表
*/
@Override
public List<StockIndexInfo> parseStockIndex(String content) {
// 1. 解析为JSON对象
JSONObject jsonObject = JSONObject.parseObject(content);
JSONObject data = jsonObject.getJSONObject("data");
if (ObjectUtils.isEmpty(data)) {
return Collections.emptyList();
}
// 2. 获取指数列表数组(data.diff)
JSONArray jsonArray = data.getJSONArray("diff");
if (jsonArray.size() <= 0) {
return Collections.emptyList();
}
// 3. 遍历数组,封装为StockIndexInfo
List<StockIndexInfo> result = new ArrayList<>(6);
jsonArray.forEach(n -> {
JSONObject tempObject = JSONObject.parseObject(n.toString());
StockIndexInfo indexInfo = new StockIndexInfo();
indexInfo.setCode(tempObject.getString("f12")); // 指数编码(如000001)
indexInfo.setName(tempObject.getString("f14")); // 指数名称(如上证指数)
indexInfo.setNowPrice(tempObject.getString("f2")); // 最新价
indexInfo.setNowProportion(tempObject.getString("f3") + "%"); // 涨跌幅(加%符号)
indexInfo.setSubPrice(tempObject.getString("f4")); // 涨跌额
indexInfo.setType(convertTypeByCode(indexInfo.getCode())); // 转换为统一类型(1-4)
result.add(indexInfo);
});
return result;
}
4.1.3 东方财富响应示例
清洗前的响应数据(包含jQuery回调):
jQuery112405795797323925824_1676954612820({"rc":0,"rt":6,"svr":183124518,"lt":1,"full":1,"dlmkts":"","data":{"total":4,"diff":[{"f1":2,"f2":3917.36,"f3":0.69,"f4":26.91,"f12":"000001","f13":1,"f14":"上证指数"},{"f1":2,"f2":13332.73,"f3":1.47,"f4":192.52,"f12":"399001","f13":0,"f14":"深证成指"},{"f1":2,"f2":3191.98,"f3":2.23,"f4":69.74,"f12":"399006","f13":0,"f14":"创业板指"},{"f1":2,"f2":4611.62,"f3":0.95,"f4":43.44,"f12":"000300","f13":1,"f14":"沪深300"}]}});
可以看到,响应中包含4个核心指数的完整数据,字段f2(最新价)、f3(涨跌幅)、f4(涨跌额)正是我们需要的核心信息。
4.2 财联社数据源(备用)
财联社数据源作为备用,其接口返回格式更简洁,无需清洗直接解析即可。核心优势是反爬机制宽松,适合作为东方财富故障时的备用方案:
/**
* 财联社指数数据抓取(备用数据源)
* @return 核心指数列表
*/
@Override
public List<StockIndexInfo> findStockIndex() {
try {
// 财联社指数接口(固定参数,包含上证、深证、创业板等核心指数)
String url = "https://x-quote.cls.cn/v2/quote/a/web/stocks/basic?app=CailianpressWeb&fields=secu_name,secu_code,trade_status,change,change_px," +
"last_px&os=web&secu_codes=sh000001,sz399001,sh000905,sz399006&sv=8.4.6&sign=7ddfd2eef7564087ff01a1782c724f43";
// 发送GET请求(自定义财联社请求头)
String content = HttpUtil.sendGet(
HttpClientConfig.proxyNoUseCloseableHttpClient(),
url,
buildDjrHeaderMap()
);
// 解析JSON数据
JSONObject jsonObject = JSONUtil.parseObj(content);
JSONObject data = jsonObject.getJSONObject("data");
Set<String> indexCodes = data.keySet();
List<StockIndexInfo> indexInfos = new ArrayList<>();
// 遍历指数编码,封装实体类
for (String code : indexCodes) {
JSONObject indexObj = data.getJSONObject(code);
StockIndexInfo indexInfo = new StockIndexInfo();
indexInfo.setCode(indexObj.getStr("secu_code")); // 指数编码(如sh000001)
indexInfo.setName(indexObj.getStr("secu_name")); // 指数名称
indexInfo.setNowPrice(indexObj.getStr("last_px")); // 最新价
// 涨跌幅:将小数转换为百分比(如0.0069→0.69%)
indexInfo.setNowProportion(BigDecimalUtil.mul100(indexObj.getBigDecimal("change")));
indexInfo.setSubPrice(indexObj.getStr("change_px")); // 涨跌额
indexInfos.add(indexInfo);
}
return indexInfos;
} catch (Exception e) {
log.error("财联社指数抓取失败", e);
return new ArrayList<>();
}
}
private Map<String,String> buildDjrHeaderMap() {
Map<String,String> headerMap = new HashMap<>(4);
headerMap.put("Host","x-quote.cls.cn");
headerMap.put("Origin","https://www.cls.cn");
headerMap.put("Referer","https://www.cls.cn/");
return headerMap;
}
五、核心实现(三):Redis实时缓存与数据实体设计
抓取到的指数数据需要实时存入Redis,保证后续查询的高效性;同时通过统一的StockIndexInfo实体类,规范不同数据源的返回格式。
5.1 数据实体设计:StockIndexInfo
实体类封装了指数的核心信息,支持不同数据源的统一适配,同时通过getDate()方法自动获取当前时间,无需手动设置:
@Data
public class StockIndexInfo implements Serializable {
private String code; // 指数编码(如000001、399001)
private String name; // 指数名称(如上证指数、深证成指)
private String nowPrice; // 最新价
private String nowProportion; // 涨幅度(带%符号)
private String subPrice; // 涨跌额
private String openPrice; // 开盘价
private String highPrice; // 最高价
private String lowPrice; // 最低价
private String yesClosePrice; // 昨日收盘价
private String change; // 涨跌比例(小数)
private Integer type; // 指数类型:1=上证,2=沪深,3=创业板,4=沪深300
private String date; // 数据更新时间
private Integer limitUp; // 涨停状态
private Integer limitDown; // 跌停状态
private Integer limitLevel; // 涨跌停级别
private String tradingValue; // 成交额
// 自动获取当前时间作为更新时间
public String getDate() {
return DateUtil.now();
}
}
5.2 Redis缓存核心代码
缓存逻辑简洁高效:以“固定前缀+指数编码”作为Redis的key,将StockIndexInfo实体直接存入Redis,后续查询时通过编码即可快速获取:
/**
* 将指数数据存入Redis缓存
* @param stockIndexInfo 指数数据实体
*/
public void updateStockIndex(StockIndexInfo stockIndexInfo) {
// 构造Redis的key:STOCK_PRICE_zs_指数编码(如STOCK_PRICE_zs_000001)
String key = Const.STOCK_PRICE + "zs_" + stockIndexInfo.getCode();
// 存入Redis(默认无过期时间,下次抓取自动覆盖)
redisUtil.set(key, stockIndexInfo);
log.debug("指数数据缓存完成,key:{},数据:{}", key, stockIndexInfo);
}
缓存后的Redis数据示例(key:STOCK_PRICE_zs_000001):
{
"code": "000001",
"name": "上证指数",
"nowPrice": "3917.36",
"nowProportion": "0.69%",
"subPrice": "26.91",
"type": 1,
"date": "2025-12-22 09:30:01"
}
六、核心优化:让监控系统更稳定、更实用
基础功能实现后,我们还需要做3个关键优化,让系统更适配生产环境,更符合实际使用需求:
6.1 多数据源故障自动切换
通过添加数据源健康检查逻辑,当东方财富抓取失败次数达到阈值时,自动切换到财联社数据源,避免监控中断:
/**
* 智能选择数据源(故障自动切换)
* @return 可用的指数数据列表
*/
private List<StockIndexInfo> selectAvailableDataSource() {
// 先尝试东方财富数据源
List<StockIndexInfo> dfData = extCrawlerService.findStockIndex();
if (CollUtil.isNotEmpty(dfData)) {
return dfData;
}
// 东方财富失败,切换到财联社数据源
log.warn("东方财富数据源抓取失败,切换到财联社数据源");
List<StockIndexInfo> drjData = crawlerDrjService.findStockIndex();
if (CollUtil.isNotEmpty(drjData)) {
return drjData;
}
// 双数据源均失败,记录严重错误
log.error("东方财富、财联社双数据源均抓取失败,本次监控中断");
return Collections.emptyList();
}
6.2 指数类型精准匹配
通过convertTypeByCode方法,将不同数据源的指数编码统一转换为1-4的类型标识,方便后续筛选和使用:
/**
* 根据指数编码转换为统一类型
* @param code 指数编码
* @return 统一类型:1=上证,2=沪深,3=创业板,4=沪深300
*/
private Integer convertTypeByCode(String code) {
switch (code) {
case "000001":
return 1; // 上证指数
case "399001":
return 2; // 深证成指
case "399006":
return 3; // 创业板指
case "000300":
return 4; // 沪深300
default:
return 0; // 其他指数
}
}
6.3 新增指数预警扩展接口
预留预警扩展接口,后续可根据自己的交易策略添加预警逻辑(如指数跌破/突破指定点位时发送邮件/钉钉提醒):
/**
* 指数预警逻辑(扩展接口)
* @param indexInfo 最新指数数据
*/
private void indexWarn(StockIndexInfo indexInfo) {
// 示例:上证指数跌破3900触发预警
if ("000001".equals(indexInfo.getCode())) {
BigDecimal nowPrice = new BigDecimal(indexInfo.getNowPrice());
if (nowPrice.compareTo(new BigDecimal("3900")) < 0) {
notifyService.sendDingTalkNotify(
String.format("【指数预警】上证指数跌破3900点,当前价:%s", indexInfo.getNowPrice())
);
}
}
}
七、最终效果:10秒级更新,毫秒级响应
部署完成后,系统将在工作日交易时段自动运行,实现三大核心效果:
- 实时更新:每10秒抓取一次最新指数数据,数据延迟控制在1秒内,远超免费行情软件;
- 稳定可靠:双数据源自动切换,单一数据源故障不影响监控;
- 高效响应:Redis缓存保障查询响应时间≤10ms,后续对接量化策略、前端展示都毫无压力。
后续可基于这套系统,轻松扩展出“指数趋势图表”“个性化预警提醒”“策略自动触发”等高级功能,让指数数据真正为你的交易决策服务。
八、系列文章预告
本文完成了量化系统的“实时数据监控层”搭建,实现了核心指数的10秒级抓取与缓存,为后续策略执行提供了精准、实时的数据支撑。下一篇文章,我们将正式进入量化策略实战环节,基于前面搭建的数据体系,实现“均线交叉策略”的自动执行与回测,让你的量化系统真正具备实战交易能力!
最后,留一个思考问题:如果需要监控更多细分指数(如科创50、中证500),你会如何修改当前的抓取逻辑?欢迎在评论区交流你的解决方案~

浙公网安备 33010602011771号