Java量化系列(三):实现东方财富历史日K线数据同步

165 阅读17分钟

在前两篇文章中,我们先后完成了股票基础列表同步和每日日K数据自动同步,搭建了量化系统的“基础数据层”和“每日增量数据层”。但对于量化策略回测而言,仅有当日和近期的日K数据远远不够——我们需要获取一只股票过去数月、数年的历史K线数据,才能验证策略在不同市场环境下的有效性。

本文作为Java量化系列的第三篇,将聚焦“历史K线数据同步”核心功能,基于SpringBoot 3.3.8 + Mybatis-Plus + Mysql8.0技术栈,实现从东方财富网站爬取指定股票的历史K线数据(支持自定义时间范围和K线类型),并完成数据解析、格式转换与批量存储。同样适合三年以上Java开发经验,希望完善量化系统历史数据层的工程师参考。

一、核心需求与设计考量

历史K线数据同步的核心诉求是“灵活性”“完整性”“可复用性”,结合量化策略回测的场景特性,我们明确了以下需求与设计原则:

1.1 核心需求

  • 支持多K线类型:可同步日K、周K、月K等不同周期的历史数据(通过参数控制,本文以日K为例);
  • 时间范围可控:支持自定义同步的起始时间(如从2025年10月10日开始),直至最新交易日;
  • 代码适配性强:支持A股所有可用股票的历史数据同步,需实现股票代码的标准化转换(如001318→0.001318,适配东方财富接口格式);
  • 异常兼容:针对接口返回的特殊格式(JSONP)进行解析,处理网络异常、数据缺失等问题;
  • 批量高效存储:解析后的历史数据批量存入Mysql,兼顾存储效率与数据一致性。

1.2 技术选型补充与接口分析

在之前技术栈基础上,针对本次历史K线同步需求,补充以下关键设计:

  • 数据来源:东方财富历史K线接口(https://push2his.eastmoney.com/api/qt/stock/kline/get),支持自定义股票代码、时间范围、K线类型,数据完整性高;
  • 格式解析:JSONP格式解析(接口返回为JSONP包裹的JSON数据,需先剥离JSONP前缀后缀);
  • 代码转换:自定义StockCodeHelper工具类,实现A股代码到东方财富接口要求的标准化格式转换(如上海股票600000→1.600000,深圳股票001318→0.001318);
  • 接口防爬:添加自定义请求头(含Host、Cookie等字段),模拟浏览器请求,避免被接口拦截;
  • 批量存储:复用Mybatis-Plus的saveBatch方法,指定批次大小(如100条/批),提升存储效率。

核心接口分析(东方财富历史K线接口):

接口URL模板:https://push2his.eastmoney.com/api/qt/stock/kline/get?cb={0}&secid={1}&ut=fa5fd1943c7b386f172d6893dbfba10b&fields1=f1,f2,f3,f4,f5,f6&fields2=f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61&klt={2}&fqt=1&beg={3}&end=20500101&lmt=800&_=

关键参数说明:

  • cb:JSONP回调函数名(固定值,如jQuery35105361642636114103_1690247737667);
  • secid:标准化股票代码(如0.001318代表深圳股票001318,1.600000代表上海股票600000);
  • klt:K线周期类型(101=日K,102=周K,103=月K,1=分钟K等);
  • beg:起始时间(格式为yyyyMMdd,如20251010代表2025年10月10日);
  • end:结束时间(固定为20500101,代表获取到最新交易日);
  • lmt:单次获取最大条数(默认800条,足够覆盖单只股票数年的日K数据)。

二、核心流程设计:从接口调用到数据存储

本次历史K线数据同步的完整流程可概括为:接口参数准备股票代码标准化转换JSONP格式接口调用JSONP数据解析(剥离前缀后缀)JSON数据提取与字段映射DTO转DO格式转换Mysql批量存储。下面我们按流程逐步拆解实现细节。

三、核心实现:分步拆解关键代码

数据表与 Do 均与之前是一致的, 为了避免读者忘记,再写一下, 后续文章均是以这个 数据表和 Do 为准。

Mysql 数据表:

CREATE TABLE `stock_history_30` (
  `id` int NOT NULL AUTO_INCREMENT COMMENT 'id自增',
  `code` varchar(10) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL COMMENT '股票的编码',
  `name` varchar(50) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL COMMENT '股票的名称',
  `curr_date` timestamp NULL DEFAULT NULL COMMENT '当天的日期不包括周六周天',
  `highest_price` decimal(18,4) DEFAULT NULL COMMENT '最高价格',
  `lowest_price` decimal(18,4) DEFAULT NULL COMMENT '最低价格',
  `closing_price` decimal(18,4) DEFAULT NULL COMMENT '收盘价',
  `opening_price` decimal(18,4) DEFAULT NULL COMMENT '开盘价',
  `yesClosing_price` decimal(18,4) DEFAULT NULL COMMENT '前收盘',
  `highest_time` timestamp NULL DEFAULT NULL COMMENT '最高价格所在的时间',
  `lowest_time` timestamp NULL DEFAULT NULL COMMENT '最低价格所在的时间',
  `open_percent` decimal(18,4) DEFAULT NULL COMMENT '开盘价比例',
  `highest_percent` decimal(18,4) DEFAULT NULL COMMENT '最高价比例',
  `lowest_percent` decimal(18,4) DEFAULT NULL COMMENT '最低价比例',
  `zt` tinyint(1) DEFAULT NULL COMMENT '是否涨停 1为涨停 0为不涨停',
  `tProportion` decimal(18,4) DEFAULT NULL COMMENT '做T的比例',
  `amplitude` decimal(18,4) DEFAULT NULL COMMENT '涨跌额',
  `amplitude_proportion` decimal(18,4) DEFAULT NULL COMMENT '涨跌幅',
  `trading_volume` decimal(18,4) DEFAULT NULL COMMENT '成交量',
  `trading_value` decimal(18,4) DEFAULT NULL COMMENT '成交金额',
  `out_dish` int DEFAULT NULL COMMENT '外盘数量',
  `inner_dish` int DEFAULT NULL COMMENT '内盘数量',
  `changing_proportion` decimal(18,4) DEFAULT NULL COMMENT '换手率',
  `than` decimal(18,4) DEFAULT NULL COMMENT '量比',
  `avg_price` decimal(18,4) DEFAULT NULL COMMENT '均价',
  `market` decimal(18,4) DEFAULT NULL COMMENT '市值',
  `lt_market` decimal(18,4) DEFAULT NULL COMMENT '流通市值',
  `static_price_ratio` decimal(18,4) DEFAULT NULL COMMENT '静态市盈率',
  `dynamic_price_ratio` decimal(18,4) DEFAULT NULL COMMENT '动态市盈率',
  `ttm_price_ratio` decimal(18,4) DEFAULT NULL COMMENT 'TTM 市盈率',
  `buy_hand` int DEFAULT NULL COMMENT '买的 前五手',
  `sell_hand` int DEFAULT NULL COMMENT '卖的 前五手',
  `appoint_than` varchar(18) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '委比',
  `flag` tinyint(1) DEFAULT '1' COMMENT '1为正常 0为删除',
  PRIMARY KEY (`id`) USING BTREE,
  KEY `idx_stock_history_1` (`code`,`curr_date`) USING BTREE,
  KEY `curr_date` (`curr_date`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=5945467 DEFAULT CHARSET=utf8mb3 ROW_FORMAT=DYNAMIC COMMENT='股票的历史交易记录表';

StockHistoryDo:

@Data
@EqualsAndHashCode(callSuper = false)
@TableName("stock_history_30")
public class StockHistoryDo 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;

    /**
     * 当天的日期不包括周六周天
     */
    @TableField("curr_date")
    private Date currDate;

    /**
     * 最低价格
     */
    @TableField("lowest_price")
    private BigDecimal lowestPrice;

    /**
     * 开盘价
     */
    @TableField("opening_price")
    private BigDecimal openingPrice;

    /**
     * 前收盘
     */
    @TableField("yesClosing_price")
    private BigDecimal yesClosingPrice;

    /**
     * 涨跌额
     */
    @TableField("amplitude")
    private BigDecimal amplitude;

    /**
     * 涨跌幅
     */
    @TableField("amplitude_proportion")
    private BigDecimal amplitudeProportion;

    /**
     * 成交量
     */
    @TableField("trading_volume")
    private BigDecimal tradingVolume;

    /**
     * 成交金额
     */
    @TableField("trading_value")
    private BigDecimal tradingValue;

    /**
     * 收盘价
     */
    @TableField("closing_price")
    private BigDecimal closingPrice;

    /**
     * 最高价格
     */
    @TableField("highest_price")
    private BigDecimal highestPrice;


    /**
     * 是否涨停 1为涨停  0为不涨停
     */
    @TableField("zt")
    private Integer zt;


    /**
     * 最低价所处的时间
     */
    @TableField("lowest_time")
    private Date lowestTime;


    /**
     * 最高价所处的时间
     */
    @TableField("highest_time")
    private Date highestTime;


    /**
     * 开盘价比例
     */
    @TableField("open_percent")
    private BigDecimal openPercent;

    /**
     * 最低价比例
     */
    @TableField("lowest_percent")
    private BigDecimal lowestPercent;


    /**
     * 最高价比例
     */
    @TableField("highest_percent")
    private BigDecimal highestPercent;



    /**
     * (最高点- 最低点) / 昨日收盘价  *100
     */
    @TableField("tProportion")
    private BigDecimal tProportion;

    /**
     * 外盘数量
     */
    @TableField("out_dish")
    private Integer outDish;

    /**
     * 内盘数量
     */
    @TableField("inner_dish")
    private Integer innerDish;
    /**
     * 换手率
     */
    @TableField("changing_proportion")
    private BigDecimal changingProportion;
    /**
     * 量比
     */
    @TableField("than")
    private BigDecimal than;

    /**
     * 均价
     */
    @TableField("avg_price")
    private BigDecimal avgPrice;


    /**
     * 市值,亿单位
     */
    @TableField("market")
    private BigDecimal market;


    /**
     * 流通市值,亿单位
     */
    @TableField("lt_market")
    private BigDecimal ltMarket;


    /**
     * 静态市盈率
     */
    @TableField("static_price_ratio")
    private BigDecimal staticPriceRatio;

    /**
     * 动态市盈率
     */
    @TableField("dynamic_price_ratio")
    private BigDecimal dynamicPriceRatio;

    /**
     * TTM 市盈率
     */
    @TableField("ttm_price_ratio")
    private BigDecimal ttmPriceRatio;

    /**
     * 买的 前五手
     */
    @TableField("buy_hand")
    private Integer buyHand;

    /**
     * 卖的 前五手
     */
    @TableField("sell_hand")
    private Integer sellHand;

    /**
     * 委比
     */
    @TableField("appoint_than")
    private String appointThan;


    /**
     * 1为正常 0为删除
     */
    @TableField("flag")
    private Integer flag;


    @TableField(exist = false)
    private String webUrl;

    @TableField(exist = false)
    private String aiSendMessage;

    public String getAiSendMessage() {
        return DateUtil.format(currDate, Const.SIMPLE_DATE_FORMAT) + " " +openingPrice + ","+ highestPrice + "," + lowestPrice + ","+ closingPrice + "," + tradingVolume;
    }
}

3.1 核心入口:同步接口与流程编排

首先实现历史K线同步的核心入口接口(采用POST请求,支持后续扩展为批量同步多只股票),负责指定股票代码、调用工具类转换代码格式、获取历史K线数据、格式转换与批量存储。核心方法为asyncKData()

核心代码实现:

@RestController
@RequestMapping("/stock/history")
public class StockHistoryController {

    @Resource
    private CrawlerStockService crawlerStockService;
    @Resource
    private StockCodeHelper stockCodeHelper;
    @Resource
    private StockHistoryAssembler stockHistoryAssembler;
    @Resource
    private StockHistoryDomainService stockHistoryDomainService;

    @Operation(description = "同步指定股票历史K线数据")
    @PostMapping("/asyncKData")
    public OutputResult asyncKData() {
        // 示例:同步股票001318的历史K线数据(可扩展为接收前端传入的股票代码列表)
        String code = "001318";
        log.info("开始同步股票 {} 的历史K线数据", code);

        try {
            // 1. 股票代码标准化转换(适配东方财富接口格式:001318→0.001318)
            String standardCode = stockCodeHelper.convertCode(code);
            // 2. 调用服务获取历史K线数据(klt=101代表日K,beg=20251010代表起始时间)
            OutputResult<List<TxStockHistoryInfo>> dataResult = crawlerStockService.kDataList(standardCode, 101, "20251010");
            List<TxStockHistoryInfo> kDataList = dataResult.getData();
            
            if (CollUtil.isEmpty(kDataList)) {
                log.info("未获取到股票 {} 的历史K线数据", code);
                return OutputResult.buildSucc("未获取到历史K线数据");
            }
            log.info("成功获取股票 {} 的历史K线数据,共 {} 条", code, kDataList.size());

            // 3. DTO转DO:将TxStockHistoryInfo转换为StockHistoryDo(适配数据库表结构)
            List<StockHistoryDo> stockHistoryDos = new ArrayList<>();
            for (TxStockHistoryInfo txStockHistoryInfo : kDataList) {
                StockHistoryDo stockHistoryDo = stockHistoryAssembler.txInfoToDo(txStockHistoryInfo);
                // 设置交易日期(从DTO的日期对象中获取)
                stockHistoryDo.setCurrDate(txStockHistoryInfo.getCurrDateObj());
                stockHistoryDos.add(stockHistoryDo);
            }

            // 4. 批量保存到Mysql(批次大小100,避免SQL过长)
            stockHistoryDomainService.saveBatch(stockHistoryDos, 100);
            log.info("股票 {} 历史K线数据同步并保存完成", code);
            return OutputResult.buildSucc("历史K线数据同步成功");
        } catch (Exception e) {
            log.error("股票 {} 历史K线数据同步失败", code, e);
            return OutputResult.buildFail("历史K线数据同步失败:" + e.getMessage());
        }
    }
}

关键注意点:

  • 代码扩展性:当前示例固定同步股票001318,实际开发中可修改为接收前端传入的codeList参数,实现多只股票批量同步;
  • 参数可配置性:K线类型(klt)和起始时间(beg)可抽取为配置项(如存入application.yml),避免硬编码;
  • 异常处理:通过try-catch捕获整个流程的异常,记录详细日志并返回友好提示,便于问题排查。

3.2 核心工具:股票代码标准化转换

东方财富接口要求股票代码为“市场标识.股票代码”格式(如上海市场1、深圳市场0),需实现convertCode方法完成格式转换,同时兼容债券等特殊标的的代码处理。

核心代码实现:

@Component
public class StockCodeHelper {

    /**
     * 转换标准股票代码格式(适配东方财富接口:上海股票→1.代码,深圳股票→0.代码)
     *
     * @param code 原始股票代码(如001318、600000)
     * @return 标准化后的带市场前缀的代码(如0.001318、1.600000)
     */
    public String convertCode(String code) {
        // 1. 校验入参:为空或已为标准化格式(含.),直接返回
        if (!StrUtil.isNotBlank(code) || code.contains(".")) {
            return code;
        }
        String validateCode = code;
        // 3. 判断股票市场类型(上海/深圳),拼接标准化格式
        StockCodeType stockCodeType = StockCodeType.getTypeByStockCode(validateCode);
        return StockCodeType.SH.equals(stockCodeType) ? "1." + code : "0." + code;
    }
}

关键补充说明:

  • StockCodeType枚举类:需自定义该枚举,通过股票代码前缀判断市场类型(如60开头→上海SH,00开头→深圳SZ,30开头→创业板SZ等);
public enum StockCodeType {
    SH(1, "上海"),
    SZ(2, "深圳"),
    CY(3, "创业板"),
    BJ(4, "北京板"),
    OTHER(5, "未知"),
    ;

    private Integer code;

    private String desc;

    private StockCodeType(Integer code, String desc) {
        this.code = code;
        this.desc = desc;
    }

    /**
     * 获取交易的方法
     *
     * @param code
     * @return
     */
    public static StockCodeType getTypeByCode(Integer code) {
        if (code == null) {
            return null;
        }
        for (StockCodeType configCodeType : StockCodeType.values()) {
            if (configCodeType.code.equals(code)) {
                return configCodeType;
            }
        }
        return null;
    }

    /**
     * 获取交易的方法
     *
     * @param code
     * @return
     */
    public static StockCodeType getTypeByStockCode(String code) {
        // 如果以 60 开头
        if (code.startsWith("68")) {
            return StockCodeType.BJ;
        } else if (code.startsWith("6")) {
            return StockCodeType.SH;
        } else if (code.startsWith("0")) {
            return StockCodeType.SZ;
        }  else if (code.startsWith("1")) {
            return StockCodeType.OTHER;
        }  else if (code.startsWith("5")) {
            return StockCodeType.OTHER;
        }else if (code.startsWith("3")) {
            return StockCodeType.CY;
        } else if (code.startsWith("83")) {
            return StockCodeType.BJ;
        } else {
            return StockCodeType.OTHER;
        }
    }

    public static Boolean isTradeType(String code) {
        // 如果以 60 开头
        StockCodeType stockCodeType = getTypeByStockCode(code);
        return stockCodeType.equals(StockCodeType.SH) || stockCodeType.equals(StockCodeType.SZ)
                || stockCodeType.equals(StockCodeType.CY);
    }

    public Integer getCode() {
        return code;
    }

    public String getDesc() {
        return desc;
    }
}

3.3 数据爬取:东方财富JSONP接口调用

实现kDataList方法调用东方财富历史K线接口,核心处理JSONP格式请求、自定义请求头、数据获取与初步清洗(剥离JSONP前缀后缀)。

核心代码实现:

@Service
public class CrawlerStockServiceImpl implements CrawlerStockService {

    @Resource
    private CrawlerService crawlerService;

    @Override
    public OutputResult<List<TxStockHistoryInfo>> kDataList(String code, Integer type, String beg) {
        try {
            // 调用爬虫服务获取历史K线数据
            List<TxStockHistoryInfo> kDataList = crawlerService.kDataList(code, type, beg);
            return OutputResult.buildSucc(kDataList);
        } catch (Exception e) {
            log.error("获取股票 {} 历史K线数据失败(类型:{},起始时间:{})", code, type, beg, e);
            return OutputResult.buildFail("获取历史K线数据失败");
        }
    }
}

@Service
public class CrawlerServiceImpl implements CrawlerService {

    // JSONP回调函数名(接口固定返回该回调包裹的JSON数据,需固定此值)
    private static final String KDATA_CB = "jQuery35105361642636114103_1690247737667";

    @Override
    public List<TxStockHistoryInfo> kDataList(String code, Integer type, String beg) {
        try {
            // 1. 拼接历史K线接口URL(替换cb、secid、klt、beg参数)
            String kdataUrl = "https://push2his.eastmoney.com/api/qt/stock/kline/get?cb={0}&secid={1}" +
                    "&ut=fa5fd1943c7b386f172d6893dbfba10b&fields1=f1,f2,f3,f4,f5,f6&fields2=f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61&klt={2}" +
                    "&fqt=1&beg={3}&end=20500101&lmt=800&_=";
            String url = MessageFormat.format(kdataUrl, KDATA_CB, code, type, beg);
            
            // 2. 构建请求头:模拟浏览器请求,避免被接口拦截
            Map<String, String> header = new HashMap<>();
            header.put("Host", "push2his.eastmoney.com");
            header.put("Cookie", "qgqp_b_id=ec2d8007963808c47e3e13c6ab114c63; st_nvi=XXXXXXXqxg-2"); // 实际使用时替换为有效Cookie
            header.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");
            
            // 3. 发送GET请求:获取JSONP格式的响应内容(添加时间戳参数避免缓存)
            String content = HttpUtil.sendGet(HttpClientConfig.proxyNoUseCloseableHttpClient(), url + MyDateUtil.getTimezone());
            log.info("东方财富历史K线接口返回内容:{}", content);
            
            // 4. JSONP格式清洗:剥离回调函数前缀(KDATA_CB + "(")和后缀(");")
            content = content.substring(KDATA_CB.length() + 1); // 去掉前缀:KDATA_CB(
            content = content.substring(0, content.length() - 2); // 去掉后缀:);
            
            // 5. 调用解析服务,将清洗后的JSON数据转换为TxStockHistoryInfo列表
            List<TxStockHistoryInfo> txStockHistoryInfos = stockInfoParser.kDataList(content);
            return CollUtil.isEmpty(txStockHistoryInfos) ? Collections.emptyList() : txStockHistoryInfos;
        } catch (Exception e) {
            log.error("同步股票 {} 历史K线数据失败(类型:{},起始时间:{})", code, type, beg, e);
            // 异常统计:自定义埋点,便于监控接口可用性
            globalWebExceptionHandlerAspect.addException();
            return null;
        }
    }
}

关键注意点:

  • JSONP格式处理:接口返回内容为KDATA_CB(JSON数据);格式,需通过字符串截取剥离前缀后缀,转换为标准JSON格式后再解析;
  • 请求头配置:必须添加HostCookie字段(Cookie需替换为有效值,可通过浏览器抓包获取),否则接口会返回403或空数据;
  • 缓存避免:URL末尾添加MyDateUtil.getTimezone()(获取当前时间戳),避免接口返回缓存数据;

Cookie 的获取,可以 登录东方财富, 然后输入股票编码,再 F12 进行获取到。 如下图:

3.4 数据解析:JSON转实体类字段映射

清洗后的JSON数据中,历史K线数据嵌套在data.klines字段中(为JSON数组,每条元素为单根K线的字符串,字段间用逗号分隔)。需实现kDataList方法提取数据并映射为TxStockHistoryInfoDTO。

核心代码实现:

@Service
public class StockInfoParserImpl implements StockInfoParser {

    @Override
    public List<TxStockHistoryInfo> kDataList(String content) {
        // 1. 将清洗后的JSON字符串转换为JSONObject
        JSONObject jsonObject = JSONObject.parseObject(content);
        // 2. 提取data字段(核心数据所在字段)
        JSONObject data = jsonObject.getJSONObject("data");
        if (ObjectUtils.isEmpty(data)) {
            log.info("历史K线数据解析:data字段为空");
            return Collections.emptyList();
        }
        // 3. 提取股票基本信息:代码和名称
        String code = data.getString("code");
        String name = data.getString("name");
        // 4. 提取klines字段(K线数据数组,每条为单根K线的字符串)
        JSONArray jsonArray = data.getJSONArray("klines");
        if (jsonArray.size() <= 0) {
            log.info("股票 {} 未获取到历史K线数据", code);
            return Collections.emptyList();
        }

        // 5. 遍历解析每条K线数据
        List<TxStockHistoryInfo> result = new ArrayList<>(jsonArray.size());
        String finalCode = code;
        String finalName = name;
        jsonArray.forEach(n -> {
            String singleContent = n.toString();
            if (StrUtil.isNotBlank(singleContent)) {
                // 按逗号拆分单根K线数据(字段顺序:日期,开盘价,收盘价,最高价,最低价,成交量,成交额,涨跌额,涨跌幅,振幅,换手率...)
                String[] splitArr = singleContent.split(Const.JOB_PARAM_SPLIT); // Const.JOB_PARAM_SPLIT为逗号分隔符
                TxStockHistoryInfo txStockHistoryInfo = new TxStockHistoryInfo();
                // 基础字段映射
                txStockHistoryInfo.setCode(finalCode); // 股票代码
                txStockHistoryInfo.setName(finalName); // 股票名称
                txStockHistoryInfo.setCurrDate(splitArr[0]); // 交易日期(格式:yyyy-MM-dd)
                txStockHistoryInfo.setCurrDateObj(DateUtil.parse(splitArr[0], Const.SIMPLE_DATE_FORMAT)); // 转换为日期对象
                // 价格字段映射(BigDecimal类型,避免精度丢失)
                txStockHistoryInfo.setOpeningPrice(new BigDecimal(splitArr[1])); // 开盘价
                txStockHistoryInfo.setClosingPrice(new BigDecimal(splitArr[2])); // 收盘价
                txStockHistoryInfo.setHighestPrice(new BigDecimal(splitArr[3])); // 最高价
                txStockHistoryInfo.setLowestPrice(new BigDecimal(splitArr[4])); // 最低价
                // 成交量(Long类型)、成交额(BigDecimal类型)
                txStockHistoryInfo.setTradingVolume(Long.parseLong(splitArr[5])); // 成交量(单位:股)
                txStockHistoryInfo.setTradingValue(new BigDecimal(splitArr[6])); // 成交额(单位:元)
                // 衍生指标映射
                txStockHistoryInfo.setZf(new BigDecimal(splitArr[7])); // 涨跌额
                txStockHistoryInfo.setAmplitudeProportion(new BigDecimal(splitArr[8])); // 涨跌幅(%)
                txStockHistoryInfo.setAmplitude(new BigDecimal(splitArr[9])); // 振幅(%)
                txStockHistoryInfo.setChangingProportion(new BigDecimal(splitArr[10])); // 换手率(%)
                
                result.add(txStockHistoryInfo);
            }
        });
        return result;
    }
}

关键注意点:

  • 字段顺序确认:klines数组中每条字符串的字段顺序是固定的(日期→开盘价→收盘价→…),需通过东方财富接口文档或抓包确认,避免映射错误;
  • 类型转换:价格、涨跌额等字段需转换为BigDecimal类型(避免浮点数精度丢失),成交量转换为Long类型(避免超出Integer范围);
  • 日期处理:将字符串格式的日期(如2025-10-10)转换为Date对象,便于后续数据库存储和策略回测时的日期筛选;
  • 空值容错:通过StrUtil.isNotBlank(singleContent)过滤空数据,避免空指针异常。

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

上述实现已满足单只股票历史K线数据同步的基础需求,但在生产环境中需补充以下优化点,确保系统稳定性、效率与数据完整性:

  1. 批量同步优化:扩展接口支持多只股票批量同步,采用线程池异步处理单只股票的同步流程,提升整体同步效率;
  2. 数据去重机制:同步前先查询数据库中已存在的历史数据,按“股票代码+交易日期”作为唯一键,避免重复插入(可通过Mybatis-Plus的insertOrUpdateBatch方法实现);
  3. 分页获取适配:当单只股票历史数据超过800条时(lmt=800),需实现分页获取逻辑(通过调整beg参数为上一批次的最后一个日期);
  4. 多K线类型支持:封装klt参数为枚举类(如KlineType.DAY=101KlineType.WEEK=102),支持日K、周K、月K等多类型同步;
  5. 监控与告警:集成监控工具(如Prometheus + Grafana)监控同步成功率、同步数据量等指标,同步失败时通过钉钉/邮件告警;
  6. 接口切换预案:准备备用历史数据接口(如雪球、同花顺接口),当东方财富接口不可用时自动切换,提升系统可用性。

五、系列文章预告

本文完成了历史K线数据的同步功能,至此我们已搭建起量化系统的“完整数据层”(基础列表+每日增量+历史存量)。

下一篇文章将进行 自选股票列表的处理,用于对基础的数据进行同步。

最后,留一个思考问题:在多只股票批量同步历史K线数据时,如何设计线程池参数(核心线程数、最大线程数、队列大小),才能在提升同步效率的同时,避免触发东方财富接口的限流机制?欢迎在评论区交流~