对于Java股票量化工程师而言,搭建量化交易系统的第一步,必然是构建可靠的基础数据层——其中股票列表数据的全量获取与实时更新,更是整个系统的“基石”。没有完整、准确的股票基础信息,后续的策略回测、实时监控、自动交易等核心功能都无从谈起。
“金亥跃江聊量化” 本系列文章将以“实战落地”为核心,基于SpringBoot 3.3.8 + Mybatis-Plus + Mysql8.0技术栈,从基础数据搭建到策略引擎实现,逐步拆解Java量化系统的开发流程。本文作为开篇,聚焦核心基础功能:股票列表数据的全量爬取、解析与数据库持久化,适合拥有三年以上Java开发经验、想切入量化领域的工程师参考。
一、核心需求与技术选型考量
在量化交易场景中,股票列表数据需要满足“全量覆盖”“可追溯”“易扩展”三个核心要求:既要包含A股市场所有股票(含主板、创业板、科创板等)的基础信息,也要记录股票的交易所归属、交易规则等关键属性,同时支持后续新增股票的自动同步。
结合需求与技术栈特性,做了如下选型设计:
- 基础框架:SpringBoot 3.3.8——成熟稳定,支持快速集成各类工具包,且对异步、切面等特性的支持更贴合量化系统的开发需求;
- ORM框架:Mybatis-Plus——在Mybatis基础上增强了批量插入、条件查询等功能,后续股票数据的批量更新、筛选效率更高;
- 数据库:Mysql8.0——支持复杂索引设计,能满足股票基础数据的存储与高频查询需求,且对日期类型、大字段的支持更完善;
- 爬虫工具:HttpUtil(自定义封装)——针对股票数据接口的特性,实现GET请求发送与响应处理,配合代理配置避免爬取限制;
- 数据解析:FastJSON + Jackson——FastJSON用于快速解析接口返回的JSON字符串,Jackson用于复杂对象的序列化与反序列化。
二、核心设计:数据库表结构与数据模型
股票列表数据的持久化核心是数据库表结构设计,结合量化场景的后续需求,我们设计了stock表,并通过Mybatis-Plus的@TableName等注解映射为StockDo实体类。
对应的 sql:
CREATE TABLE `stock` (
`id` int NOT NULL AUTO_INCREMENT COMMENT 'id编号自增',
`code` varchar(8) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL COMMENT '股票编号',
`name` varchar(50) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL COMMENT '股票的名称',
`exchange` tinyint(1) NOT NULL COMMENT '股票的标识 0为深圳 1为上海 2为北京',
`full_code` varchar(8) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL COMMENT '股票代码的全称',
`can_use` tinyint unsigned DEFAULT '1' COMMENT '是否可以被使用 1为使用 0为不使用',
`can_rong` tinyint DEFAULT '2' COMMENT '是否可以融资 1为可以 0为不可以 2为不可用',
`release_date` timestamp NULL DEFAULT NULL COMMENT '上市日期',
`point` tinyint(1) DEFAULT '0' COMMENT '是否是指数 0为股票 1为指数 2为 etf 3为可转债',
`trade_day` tinyint(1) DEFAULT '1' COMMENT '交易天 0为T+0 1为非T+0',
`area_code` varchar(15) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '地区编码',
`area_name` varchar(40) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '地区名称',
`create_time` timestamp NULL DEFAULT NULL COMMENT '创建时间',
`create_user` varchar(10) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '创建人',
`flag` tinyint(1) DEFAULT NULL COMMENT '是否删除 1为正常 2为删除',
PRIMARY KEY (`id`) USING BTREE,
KEY `stock_code_IDX` (`code`) USING BTREE,
KEY `stock_full_code_IDX` (`full_code`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=6906 DEFAULT CHARSET=utf8mb3 ROW_FORMAT=DYNAMIC COMMENT='股票信息基本表';
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("stock")
public class StockDo implements Serializable {
private static final long serialVersionUID = 1L;
/**
* id编号自增
*/
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
/**
* 股票编号
*/
@TableField("code")
private String code;
/**
* 股票的名称
*/
@TableField("name")
private String name;
/**
* 股票的标识 0为深圳 1为上海 2为北京
*/
@TableField("exchange")
private Integer exchange;
/**
* 股票代码的全称
*/
@TableField("full_code")
private String fullCode;
@TableField("can_use")
private Integer canUse;
@TableField("can_rong")
private Integer canRong;
/**
* 创建时间
*/
@TableField("release_date")
private Date releaseDate;
/**
* 是否是指数 0为股票 1为指数 2为 etf 3为可转债
*/
@TableField("point")
private Integer point;
/**
* 0 为 T+0 1为T+1
*/
@TableField("trade_day")
private Integer tradeDay;
@TableField("area_code")
private String areaCode;
@TableField("area_name")
private String areaName;
/**
* 创建时间
*/
@TableField("create_time")
private Date createTime;
/**
* 创建人
*/
@TableField("create_user")
private String createUser;
/**
* 是否删除 1为正常 2为删除
*/
@TableField("flag")
private Integer flag;
}
这里重点解读几个核心字段的设计逻辑(避免冗余,聚焦关键):
code与fullCode:code为股票简称(如600036),fullCode为带交易所前缀的完整代码(如SH600036),便于后续区分不同交易所的股票;exchange:交易所标识(0=深圳、1=上海、2=北京),后续策略可按交易所筛选股票;point:标的类型(0=股票、1=指数、2=ETF、3=可转债),支持后续多类型标的的统一管理;tradeDay:交易规则(0=T+0、1=T+1),为后续策略的交易逻辑提供基础依据;flag:删除标识(1=正常、2=删除),采用逻辑删除而非物理删除,避免历史数据丢失影响回测。
补充说明:实体类中can_use(是否可用)、can_rong(是否融资融券)等字段,是为后续策略筛选标的预留的扩展字段,本次核心聚焦列表更新,暂不展开其业务逻辑。
三、核心实现:从接口爬取到数据保存的完整流程
本次功能的核心流程为:分页调用股票接口 → 解析返回数据 → 数据格式转换 → 批量保存到数据库,下面逐步拆解每个环节的实现细节与关键注意点。
3.1 分页爬取:接口调用与防爬处理 (重点)
我们选用东方财富的公开接口 20.push2.eastmoney.com/api/qt/clis… 作为数据来源,该接口支持分页查询,核心参数说明:
pn:页码,通过占位符{0}实现动态分页;pz:每页条数,这里设置为100,平衡爬取效率与接口压力;fs:标的筛选条件,包含A股所有股票、ETF、可转债等类型,确保全量覆盖。
爬取核心代码实现(已简化关键逻辑):
@Override
public List<DownloadStockInfo> getStockList() {
try {
boolean stopSearch = false;
int page = 1;
List<DownloadStockInfo> allResultList = new ArrayList<>();
do {
// 动态拼接分页URL
String url = MessageFormat.format("https://20.push2.eastmoney.com/api/qt/clist/get?pn={0}&pz=100&np=5&fid=f3&fields=f3,f10,f12,f14,f2,f13,f15,f16,f17,f5,f18,f16&fs=m:0+t:6,m:0+t:13,m:0+t:80,m:1+t:2,m:1+t:23,b:MK0021,b:MK0022,b:MK0023,b:MK0024", page);
try {
// 发送GET请求(无代理模式,可根据需求切换代理)
String content = HttpUtil.sendGet(HttpClientConfig.proxyNoUseCloseableHttpClient(),url,buildDfHeaderMap());
log.info("第 {} 页获取数据: {}", page, content);
// 解析当前页数据
List<DownloadStockInfo> stockPoolInfos = stockInfoParser.parseStockInfoList(content);
if (!CollUtil.isEmpty(stockPoolInfos)) {
allResultList.addAll(stockPoolInfos);
page++; // 分页递增
} else {
stopSearch = true; // 无数据则停止爬取
}
} catch (Exception e) {
log.warn("获取第{}页股票数据出错,URL:{}", page, url);
throw e;
}
ThreadUtil.safeSleep(5000); // 5秒间隔,避免触发接口限流
} while (!stopSearch);
return allResultList;
} catch (Exception e) {
log.error("全量获取股票列表失败", e);
return Collections.emptyList();
}
}
关键注意点:
- 防爬处理:通过
ThreadUtil.safeSleep(5000)设置5秒爬取间隔,避免高频请求触发接口限流;同时封装buildDfHeaderMap()方法添加请求头(如User-Agent),模拟浏览器请求; - 熔断降级:添加
@CoolDownCircuit自定义注解(基于Spring AOP实现),避免接口异常时大量重试导致系统资源耗尽; - 分页终止条件:当解析当前页数据为空时,说明已获取所有数据,设置
stopSearch = true终止循环,避免无效请求。
3.2 数据解析:JSON转实体类的核心逻辑 (重点)
接口返回的是JSON格式字符串,核心数据嵌套在data.diff字段中(为Map结构,key为股票唯一标识,value为股票详情)。解析核心逻辑为:先提取data.diff数据,再映射为我们自定义的DownloadStockInfoDTO(数据传输对象)。
返回的格式如下:
{
"rc": 0,
"rt": 6,
"svr": 177617654,
"lt": 1,
"full": 1,
"dlmkts": "",
"data": {
"total": 6808,
"diff": {
"0": {
"f2": 3732,
"f3": -2000,
"f5": 168473,
"f10": 308,
"f12": "300723",
"f13": 0,
"f14": "一品红",
"f15": 3800,
"f16": 3732,
"f17": 3732,
"f18": 4665
},
"1": {
"f2": 12675,
"f3": -1867,
"f5": 49923,
"f10": 212,
"f12": "688109",
"f13": 1,
"f14": "品茗科技",
"f15": 15200,
"f16": 12601,
"f17": 15051,
"f18": 15585
},
"2": {
"f2": 13160,
"f3": -1170,
"f5": 265860,
"f10": 155,
"f12": "688521",
"f13": 1,
"f14": "芯原股份",
"f15": 14188,
"f16": 13160,
"f17": 14150,
"f18": 14904
},
"3": {
"f2": 16206,
"f3": -1134,
"f5": 178236,
"f10": 96,
"f12": "300751",
"f13": 0,
"f14": "迈为股份",
"f15": 17662,
"f16": 16200,
"f17": 17500,
"f18": 18279
},
"4": {
"f2": 17559,
"f3": -1069,
"f5": 123772,
"f10": 77,
"f12": "688195",
"f13": 1,
"f14": "腾景科技",
"f15": 18800,
"f16": 17451,
"f17": 18500,
"f18": 19660
}
}
}
}
解析核心代码实现:
@Override
public List<DownloadStockInfo> parseStockInfoList(String content) throws JsonProcessingException {
JSONObject jsonObject = JSONObject.parseObject(content);
JSONObject data = null;
try {
data = jsonObject.getJSONObject("data");
if (ObjectUtils.isEmpty(data)) {
return Collections.emptyList();
}
} catch (Exception e) {
return Collections.emptyList();
}
if (!data.containsKey("diff")) {
return Collections.emptyList();
}
// 转换为自定义响应对象(DfApiResponse包含data.diff结构)
ObjectMapper objectMapper = new ObjectMapper();
DfApiResponse apiResponse = objectMapper.readValue(content, DfApiResponse.class);
Map<String, DfDiffItem> diffMap = apiResponse.getData().getDiff();
if (CollUtil.isEmpty(diffMap)) {
return Collections.emptyList();
}
// 映射为DownloadStockInfo列表
List<DownloadStockInfo> result = new ArrayList<>();
diffMap.forEach((key, diffItem) -> {
DownloadStockInfo downloadStockInfo = new DownloadStockInfo();
downloadStockInfo.setCode(diffItem.getF12()); // 股票代码
downloadStockInfo.setName(diffItem.getF14()); // 股票名称
downloadStockInfo.setExchange(diffItem.getF13()); // 交易所标识
// 生成完整股票代码(如SH600036)
downloadStockInfo.setFullCode(StockUtil.getFullCode(downloadStockInfo.getCode()));
// 处理股票可用状态(基于当前价格判断)
Integer price = Integer.parseInt(diffItem.getF2());
downloadStockInfo.setCanUse((price == null || price == 0) ? 0 : 1);
downloadStockInfo.setNowPrice(price); // 当前价格
downloadStockInfo.setHighPrice(Integer.parseInt(diffItem.getF15())); // 当日最高价
downloadStockInfo.setLowerPrice(Integer.parseInt(diffItem.getF16())); // 当日最低价
result.add(downloadStockInfo);
});
return result;
}
关键注意点:
- 字段映射:接口返回的字段以
F+数字命名(如F12=股票代码、F14=股票名称),需要通过文档或抓包确认字段含义,避免映射错误; - 格式转换:接口返回的价格字段为字符串类型,需转换为Integer(这里注意:实际场景中价格可能包含小数,建议用BigDecimal,本文为简化示例用Integer);
- 工具类封装:
StockUtil.getFullCode()为自定义工具方法,根据股票代码前缀(如60开头为上海、00开头为深圳)生成完整代码,便于后续区分。
这儿是:
private static final List<String> shList;
private static final List<String> shThreeList;
private static final List<String> CODES_SH_A = Arrays.asList("600", "601", "603", "605", "688", "689");
private static final List<String> CODES_SH_ETF = Arrays.asList("51", "56", "58");
private static final List<String> CODES_SH_CB = Arrays.asList("100", "110");
private static final List<String> CODES_SZ_A = Arrays.asList("000", "001", "002", "003", "004", "300", "301");
private static final List<String> CODES_SZ_ETF = Collections.singletonList("15");
private static final List<String> CODES_SZ_CB = Arrays.asList("12");
private static final List<String> CODES_BJ_A = Arrays.asList("83", "87", "43");
private static final List<String> CODES_BJ_ETF = Collections.emptyList();
private static final List<String> CODES_BJ_CB = Collections.emptyList();
private static final Map<String,String> bullMap;
static{
shList= Arrays.asList("5","6","9");
shThreeList=Arrays.asList("009","126","110","201","202","203","204");
}
/**
* 根据股票代码获取完整的股票代码(包含交易所标识)
*
* @param stockCode 股票代码
* @return 完整的股票代码(包含交易所标识)
*/
public static String getFullCode(String stockCode){
if (!StrUtil.isNotBlank(stockCode)||stockCode.length()<3) {
return stockCode;
}
String one = stockCode.substring(0, 1);
String three = stockCode.substring(0, 3);
if (shList.contains(one)) {
return ExchangeType.SH.getDesc()+stockCode;
} else {
if (shThreeList.contains(three)) {
return ExchangeType.SH.getDesc()+stockCode;
} else {
return ExchangeType.SZ.getDesc()+stockCode;
}
}
}
3.3 数据保存:批量插入与重复过滤
解析得到DownloadStockInfo列表后,需要先过滤掉已存在的股票(避免重复插入),再转换为StockDo实体类,最后通过Mybatis-Plus的saveBatch方法批量插入数据库。
核心保存逻辑实现:
// 2. 过滤新增股票(排除已存在的代码)
Date now = DateUtil.date();
// 主要代码
List<DownloadStockInfo> downloadStockInfoList = crawlerService.getStockList();
if (CollUtil.isEmpty(downloadStockInfoList)) {
log.error("同步时未获取到股票列表信息");
return OutputResult.buildFail(ResultCode.STOCK_ASYNC_FAIL);
}
List<StockDo> stockList=new ArrayList<>();
downloadStockInfoList.stream().forEach(n -> {
// DTO转DO(通过StockAssembler封装转换逻辑)
StockDo stockDo = stockAssembler.downInfoToDO(n);
// 补充基础字段
stockDo.setCreateUser("async"); // 创建人(异步任务)
stockDo.setCreateTime(now); // 创建时间
stockDo.setFlag(DataFlagType.NORMAL.getCode()); // 正常状态
stockList.add(stockDo);
});
// 3. 批量插入数据库
if (CollUtil.isEmpty(stockList)) {
return OutputResult.buildSucc(ResultCode.STOCK_ASYNC_NO_CHANGE);
}
log.info("本次同步新增股票编码:{}", stockList.stream().map(StockDo::getCode).collect(Collectors.toList()));
// 批量插入(批次大小100,避免SQL过长)
boolean saveBatch = stockDomainService.saveBatch(stockList, 100);
关键注意点:
- 去重逻辑:先查询数据库中正常状态(flag=1)的股票代码列表,过滤掉已存在的股票,避免重复插入导致主键冲突;
- 批量插入优化:Mybatis-Plus的
saveBatch方法支持指定批次大小(这里设为100),避免一次性插入过多数据导致SQL语句过长; - 字段补充:
createUser设为“async”,标识该数据由异步任务同步,便于后续问题排查。
四、核心优化点与后续扩展
本次实现的股票列表更新功能,已满足基础量化场景需求,但在生产环境中还需补充以下优化点:
- 可以增加 查询股票列表,包括关键词查询的功能
- 异步化处理:通过Spring Task 定时执行股票列表同步任务(如每日开盘前执行),避免阻塞主线程;
- 数据更新逻辑:当前仅支持新增股票,后续需补充更新逻辑(如股票名称变更、交易规则调整),通过
code作为唯一键执行“新增或更新”操作; - 异常重试机制:对爬取失败的页码,添加重试机制(结合指数退避策略),避免因网络波动导致数据缺失;
- 监控告警:添加自定义埋点,监控爬取成功率、数据量变化等指标,异常时通过邮件或钉钉告警;
- 代理池支持:当单IP爬取受限的,可集成代理池工具,动态切换IP地址。
后续系列文章预告: 下一篇将聚焦“股票当前日K线数据的爬取与存储”,基于本次实现的股票列表,进一步完善基础数据层,为后续策略回测提供数据支撑。
评论区或者私信联系博主,可以领取 目前 stock 表的全部的数据,减少你手动同步的步骤。