在前八篇内容中,我们已经搭建了股票数据爬取、自选股管理、统计分析、邮件推送等核心功能,形成了"数据获取-分析-触达"的完整闭环。但量化系统的基础是"数据准确性"------股票市场每天都可能有新股上市、老股更名或退市,如果数据库中的股票列表无法及时同步,会导致后续爬取、统计功能出现异常(比如爬取已退市股票数据失败,或遗漏新股行情)。
本文作为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表数据量会随时间增长,需制定归档策略(如按月归档历史日志到备份表),提升查询效率。
七、系列文章预告
本文完成了量化系统的"数据基础层"优化,实现了股票列表的自动同步与变更监控,确保了后续所有业务(爬取、统计、推送)的数据准确性。下一篇文章将聚焦 "指数的实时数据和保存",对常见的指数进行处理。