Java量化系列(一):实现股票列表全量更新核心功能

74 阅读13分钟

对于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;


}

这里重点解读几个核心字段的设计逻辑(避免冗余,聚焦关键):

  • codefullCodecode为股票简称(如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”,标识该数据由异步任务同步,便于后续问题排查。

四、核心优化点与后续扩展

本次实现的股票列表更新功能,已满足基础量化场景需求,但在生产环境中还需补充以下优化点:

  1. 可以增加 查询股票列表,包括关键词查询的功能
  2. 异步化处理:通过Spring Task 定时执行股票列表同步任务(如每日开盘前执行),避免阻塞主线程;
  3. 数据更新逻辑:当前仅支持新增股票,后续需补充更新逻辑(如股票名称变更、交易规则调整),通过code作为唯一键执行“新增或更新”操作;
  4. 异常重试机制:对爬取失败的页码,添加重试机制(结合指数退避策略),避免因网络波动导致数据缺失;
  5. 监控告警:添加自定义埋点,监控爬取成功率、数据量变化等指标,异常时通过邮件或钉钉告警;
  6. 代理池支持:当单IP爬取受限的,可集成代理池工具,动态切换IP地址。

后续系列文章预告: 下一篇将聚焦“股票当前日K线数据的爬取与存储”,基于本次实现的股票列表,进一步完善基础数据层,为后续策略回测提供数据支撑。

评论区或者私信联系博主,可以领取 目前 stock 表的全部的数据,减少你手动同步的步骤。