Java量化系列(1-4)

Java量化系列(一):实现股票列表全量更新核心功能_东方财富 api java封装开源-CSDN博客

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


}
text
 

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

  • 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 分页爬取:接口调用与防爬处理 (重点)

我们选用东方财富的公开接口 https://20.push2.eastmoney.com/api/qt/clist/get?pn=1&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 作为数据来源,该接口支持分页查询,核心参数说明:

  • 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();
    }
}
text
 

关键注意点:

  • 防爬处理:通过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
      }
    }
  }
}
text
 

解析核心代码实现:

@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;
}
text
 

关键注意点:

  • 字段映射:接口返回的字段以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;
            }
        }
    }
text
 

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);
text
 

关键注意点:

  • 去重逻辑:先查询数据库中正常状态(flag=1)的股票代码列表,过滤掉已存在的股票,避免重复插入导致主键冲突;
  • 批量插入优化:Mybatis-Plus的saveBatch方法支持指定批次大小(这里设为100),避免一次性插入过多数据导致SQL语句过长;
  • 字段补充:createUser设为“async”,标识该数据由异步任务同步,便于后续问题排查。

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

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

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

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

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

Java量化系列(二):基于SpringBoot实现股票日K数据每日自动同步-CSDN博客

炒股久了就会发现:指数定方向,情绪定强度。上证指数涨了不代表能赚钱,要是涨停股寥寥无几、下跌股扎堆,大概率是“指数虚涨”;反之,哪怕指数微涨,但涨停股多、赚钱效应足,行情就有参与价值。而能最直观反映市场情绪的,就是同花顺的核心行情数据——包含大盘评分、涨跌停数量、涨跌分布等关键信息。

上一篇我们搞定了大盘指数的实时监控,这一次(系列第十一篇),我们升级量化数据体系:打造同花顺核心行情抓取神器!核心是工作日交易时段按精准Cron表达式(每10分钟1次)抓取同花顺行情接口,解析出大盘评分、涨跌数量、涨跌停分布等核心情绪数据,实时缓存到Redis,让你毫秒级获取市场情绪全貌,避开“指数陷阱”,精准把握交易节奏!

一、为啥非要抓同花顺行情?3个核心价值,秒杀普通行情软件

可能有朋友疑惑:“我看盘软件里也能看到涨跌停数据,为啥还要专门抓取?” 其实,同花顺的核心行情数据,藏着普通软件没有的3个“交易密码”,这正是我们动手的核心原因:

  • 市场情绪量化评分:直接给出大盘赚钱效应评分(0-10分),新手也能秒懂“当前市场能不能参与”,不用再自己瞎琢磨;
  • 全维度涨跌分布:从“跌停~-8%”到“8%~涨停”分10个区间统计股票数量,一眼看清资金是集中在强势股还是弱势股;
  • 赚钱效应追踪:包含昨日涨停今日收益数据,能判断短线热点的持续性,这是做短线交易的核心参考。
  • 无干扰无延迟:跳过同花顺软件的广告和冗余功能,直接抓取核心数据,还能自定义缓存和后续预警,完美对接我们的量化系统。

更关键的是,我们用Java实现的这套抓取系统,能按自己的节奏精准调度,数据实时存入Redis,后续不管是做策略触发还是数据回溯,都能直接复用,真正把数据主动权握在自己手里。

二、核心需求与技术选型:精准、稳定、可复用

2.1 核心需求拆解

  1. 定时抓取:工作日9:00-11:00、13:00-14:00时段,每10分钟抓取1次(Cron:30 5,15,25,35,45,55 9,10,11,13,14 ? * 1-5),精准卡在每10分钟的30秒触发,避开行情数据更新高峰;
  2. 数据完整:抓取同花顺大盘评分、涨跌数量、涨跌停数量、10档涨跌分布、昨日涨停今日收益等核心数据;
  3. 稳定抓取:自定义请求头(含Cookie、Hexin-V等关键参数),模拟浏览器访问,避免被同花顺接口拦截;
  4. 实时缓存:解析后的数据封装为ThsHqInfoDto对象,存入Redis,key固定为“stock_public:now:thsHq”,方便后续快速查询;
  5. 异常兼容:抓取失败或数据为空时直接返回,不影响系统其他功能运行。

2.2 核心技术选型(延续系列框架,降低复用成本)

  • 定时调度:Spring Scheduler,无需额外中间件,直接用Cron表达式精准控制触发时机,和上一篇的指数监控系统无缝兼容;
  • 数据抓取:HttpClient,支持自定义请求头和编码格式(本次需指定gbk编码),完美适配同花顺接口要求;
  • 数据解析:FastJSON,高效解析同花顺返回的JSON数据,精准提取zdt_data、zdfb_data等核心字段;
  • 缓存存储:Redis,内存级存储,直接存储Java对象,保证查询响应时间≤10ms;
  • 数据封装:ThsHqInfoDto实体类,统一封装所有核心字段,还自带评分说明、涨跌分布映射等自动计算逻辑,开箱即用。

三、核心实现(一):定时调度与主流程搭建

整个系统的核心入口是定时任务方法refreshHq,负责串联“触发抓取→数据校验→设置时间戳→缓存入库”全流程,逻辑清晰且可扩展。

3.1 关键Cron表达式解析

本次使用的Cron表达式:30 5,15,25,35,45,55 9,10,11,13,14 ? * 1-5,逐位拆解核心逻辑,确保精准触发:

  • 30:第30秒触发(避开整点/整10分的接口访问高峰);
  • 5,15,25,35,45,55:仅在每个小时的5分、15分、25分、35分、45分、55分这6个时间点触发;
  • 9,10,11,13,14:仅在9点、10点、11点、13点、14点这几个交易核心时段执行;
  • ?:日位不指定(因星期位已限定,避免时间冲突);
  • *:任意月份都执行;
  • 1-5:仅周一到周五(工作日)执行,自动避开周末休市。

3.2 主流程核心代码(可直接复制复用)

主方法refreshHq支持传入sendMessage参数(预留消息推送扩展),核心逻辑简洁高效,注释清晰:

/**
 * 定时刷新同花顺行情数据并缓存到Redis
 * @param sendMessage 是否发送消息提醒(预留扩展)
 */
public void refreshHq(boolean sendMessage) {
    // 1. 调用抓取服务,获取同花顺行情数据
    ThsHqInfoDto thqInfo = extCrawlerService.findThqInfo();
    // 2. 数据为空则直接返回,避免无效操作
    if (ObjectUtils.isEmpty(thqInfo)) {
        return;
    }
    // 3. 设置数据更新时间戳(当前时间)
    thqInfo.setTimestamp(DateUtil.now());
    // 4. 存入Redis,key固定为stock_public:now:thsHq,覆盖旧数据
    redisUtil.set("stock_public:now:thsHq", thqInfo);
    // 预留:如需消息推送,可在此处添加sendMessage相关逻辑
    // if (sendMessage) { notifyService.sendThsHqNotify(thqInfo); }
}
text
 

四、核心实现(二):同花顺数据抓取(关键:请求头构造)

同花顺接口有一定的反爬机制,核心是要构造正确的请求头(含Host、Referer、Hexin-V、Cookie等关键参数),否则会返回403或空数据。下面是完整的抓取实现。

4.1 抓取核心代码(含接口地址与编码设置)

/**
 * 抓取同花顺核心行情数据
 * @return 封装后的同花顺行情对象
 */
@Override
public ThsHqInfoDto findThqInfo() {
    try {
        // 1. 同花顺行情核心接口(固定地址,无需动态拼接)
        String url = "https://q.10jqka.com.cn/api.php?t=indexflash";
        // 2. 发送GET请求:指定代理、请求头、编码格式(gbk)
        String content = HttpUtil.sendGet(
                HttpClientConfig.proxyNoUseCloseableHttpClient(),
                url,
                getBackTestHeaderMap(), // 自定义请求头(关键)
                "gbk" // 同花顺接口返回数据编码为gbk,必须指定
        );
        // 3. 调用解析方法,返回封装后的对象
        return stockInfoParser.parseThsHq(content);
    } catch (Exception e) {
        // 4. 异常捕获:记录日志(含IP),返回null
        log.error("{} 获取同花顺行情出错", ThreadLocalUtils.getIp(), e);
        return null;
    }
}
text
 

4.2 关键:请求头构造方法(反爬核心)

同花顺接口必须携带Hexin-V和Cookie参数才能正常返回数据,这里通过getBackTestHeaderMap方法构造完整请求头,其中Cookie需要替换为自己的同花顺Cookie(登录后从浏览器开发者工具获取):

/**
 * 构造同花顺抓取所需的请求头(反爬关键)
 * @return 完整的请求头Map
 */
private Map<String, String> getBackTestHeaderMap() {
    Map<String, String> header = new HashMap<>();
    // 1. 获取Hexin-V版本号(通过工具类获取,也可固定最新版本)
    WenCaiVDto vDto = wenCaiVHelper.getV();
    String v = vDto.getVersion();
    
    // 2. 核心请求头参数设置
    header.put("Host", "q.10jqka.com.cn"); // 指定主机
    header.put("Referer", "https://q.10jqka.com.cn"); // 模拟从同花顺官网跳转
    header.put("Hexin-V", v); // 同花顺接口必需的版本参数
    
    // 3. 设置Cookie(替换为自己的同花顺Cookie,登录后获取)
    String cookie_user = "同花顺对应的Cookie";
    String cookie = MessageFormat.format(cookie_user, v);
    header.put("Cookie", cookie);
    
    return header;
}
text
 

提示:Cookie获取方法:打开同花顺官网(https://q.10jqka.com.cn),登录后按F12打开开发者工具,在Network面板找到indexflash相关请求,复制其Cookie值即可。

五、核心实现(三):数据解析与实体类封装

抓取到的同花顺数据是JSON格式,需要通过parseThsHq方法解析,提取zdt_data(涨跌停数据)、zdfb_data(涨跌分布数据)、jrbx_data(昨日涨停数据)等核心字段,最终封装为ThsHqInfoDto对象。

5.1 数据解析核心代码

/**
 * 解析同花顺行情JSON数据
 * @param content 抓取到的gbk编码字符串
 * @return 封装后的ThsHqInfoDto对象
 */
@Override
public ThsHqInfoDto parseThsHq(String content) {
    // 1. 将字符串转换为JSON对象
    JSONObject jsonObject = JSONObject.parseObject(content);
    // 2. 校验核心数据字段(zdt_data不能为空)
    JSONObject zdtObject = jsonObject.getJSONObject("zdt_data");
    if (ObjectUtils.isEmpty(zdtObject)) {
        return null;
    }

    // 3. 提取各模块数据(涨跌停、涨跌分布、昨日涨停)
    JSONObject jrbxObject = jsonObject.getJSONObject("jrbx_data");
    JSONObject zdfbObject = jsonObject.getJSONObject("zdfb_data");

    // 4. 封装为ThsHqInfoDto对象
    ThsHqInfoDto thsHqInfoDto = new ThsHqInfoDto();
    thsHqInfoDto.setScore(jsonObject.getDouble("dppj_data")); // 大盘评分
    // 昨日涨停相关数据
    thsHqInfoDto.setTimeDataList(JSON.parseArray(jrbxObject.getString("time"), String.class));
    thsHqInfoDto.setYesZtPercent(jrbxObject.getDouble("last_zdf"));
    thsHqInfoDto.setYesZtPercentDataList(JSON.parseArray(jrbxObject.getString("data"), Double.class));
    // 涨跌数量
    thsHqInfoDto.setZnum(zdfbObject.getInteger("znum")); // 上涨数量
    thsHqInfoDto.setDnum(zdfbObject.getInteger("dnum")); // 下跌数量
    // 涨跌分布数据(10档区间)
    thsHqInfoDto.setZtfbDataList(JSON.parseArray(zdfbObject.getString("zdfb"), Integer.class));
    // 涨跌停数量
    JSONObject lastZdtObject = zdtObject.getJSONObject("last_zdt");
    thsHqInfoDto.setZtNum(lastZdtObject.getInteger("ztzs")); // 涨停数量
    thsHqInfoDto.setDtNum(lastZdtObject.getInteger("dtzs")); // 跌停数量
    // 涨跌停详细数据列表
    thsHqInfoDto.setZtNumDataList(JSON.parseArray(zdtObject.getString("ztzs"), Integer.class));
    thsHqInfoDto.setDtNumDetailList(JSON.parseArray(zdtObject.getString("dtzs"), Integer.class));

    return thsHqInfoDto;
}
text
 

5.2 核心实体类:ThsHqInfoDto(自带智能计算逻辑)

这个实体类不仅封装了所有核心字段,还自带3个智能计算方法,无需额外编码就能获取直观的市场信息,非常实用:

@Data
public class ThsHqInfoDto implements Serializable {
    private String timestamp; // 数据更新时间戳
    // 评分相关(自动生成评分说明和评分类型)
    private Double score;
    private String scoreMessage;
    private Integer scoreType;

    // 昨日涨停今日收益
    private Double yesZtPercent;

    // 涨跌数量
    private Integer znum;
    private Integer dnum;
    // 涨跌分布映射(自动将列表转为可读性强的Map)
    private Map<String, Integer> ztfbMessageDataMap;

    // 涨跌停数量
    private Integer ztNum;
    private Integer dtNum;
    // 涨跌分布列表(10档区间)
    private List<Integer> ztfbDataList;

    // 时间集合(HH:mm)
    private List<String> timeDataList;
    // 昨日涨停收益详细列表
    private List<Double> yesZtPercentDataList;
    // 涨跌停数量详细列表
    private List<Integer> ztNumDataList;
    private List<Integer> dtNumDetailList;

    // 智能生成评分说明(无需手动设置)
    public String getScoreMessage() {
        if (score == null) {
            return "";
        }
        scoreType = score.intValue();
        if (score < 2.5) {
            return "大盘风险极大,请勿参与";
        } else if (score >= 2.5 && score < 4) {
            return "大盘风险较大,请谨慎参与";
        } else if (score >= 4 && score < 6) {
            return "大盘震荡,适当参与";
        } else if (score >= 6 && score < 8) {
            return "大盘走势良好,积极参与";
        } else if (score >= 8) {
            return "大盘走势极好,积极参与";
        } else {
            return "";
        }
    }

    // 智能生成评分类型(整数)
    public Integer getScoreType() {
        if (scoreType != null) {
            return scoreType;
        }
        scoreType = score.intValue();
        return scoreType;
    }

    // 智能将涨跌分布列表转为Map(可读性更强)
    public Map<String, Integer> getZtfbMessageDataMap() {
        if (CollUtil.isEmpty(ztfbDataList)) {
            return null;
        }
        Map<String, Integer> resultMap = new LinkedHashMap<>();
        resultMap.put("跌停~ -8", ztfbDataList.get(0));
        resultMap.put("-8 ~ -6", ztfbDataList.get(1));
        resultMap.put("-6 ~ -4", ztfbDataList.get(2));
        resultMap.put("-4 ~ -2", ztfbDataList.get(3));
        resultMap.put("-2 ~ 0", ztfbDataList.get(4));
        resultMap.put("0~ 2", ztfbDataList.get(5));
        resultMap.put("2~ 4", ztfbDataList.get(6));
        resultMap.put("4~ 6", ztfbDataList.get(7));
        resultMap.put("6~ 8", ztfbDataList.get(8));
        resultMap.put("8~ 涨停", ztfbDataList.get(9));
        return resultMap;
    }
}
text
 

重点说明:这3个get方法(getScoreMessage、getScoreType、getZtfbMessageDataMap)都是“自动计算”的,只要设置了score和ztfbDataList,就能直接获取对应的评分说明和涨跌分布Map,比如score=5.3时,会自动返回“大盘震荡,适当参与”,非常适合直接对接前端展示或策略判断。

六、核心实现(四):Redis缓存效果与查询验证

数据解析封装后,通过redisUtil.set方法存入Redis,key固定为“stock_public:now:thsHq”,后续查询时直接通过该key获取对象,毫秒级响应。

6.1 Redis缓存数据示例

缓存后的JSON数据(格式化后),包含所有核心信息,可读性极强:

[
    "top.yueshushu.stock.mode.dto.extension.ThsHqInfoDto",
    {
        "timestamp": "2025-12-22 15:30:02",
        "score": 5.3,
        "scoreMessage": "大盘震荡,适当参与",
        "scoreType": 5,
        "yesZtPercent": 2.27,
        "znum": 2984,
        "dnum": 2265,
        "ztfbMessageDataMap": [
            "java.util.LinkedHashMap",
            {
                "跌停~ -8": 22,
                "-8 ~ -6": 18,
                "-6 ~ -4": 41,
                "-4 ~ -2": 243,
                "-2 ~ 0": 1941,
                "0~ 2": 2241,
                "2~ 4": 514,
                "4~ 6": 210,
                "6~ 8": 75,
                "8~ 涨停": 139
            }
        ],
        "ztNum": 105,
        "dtNum": 8,
        "ztfbDataList": [
            "java.util.ArrayList",
            [22,18,41,243,1941,2241,514,210,75,139]
        ],
        "timeDataList": null,
        "yesZtPercentDataList": null,
        "ztNumDataList": null,
        "dtNumDetailList": null
    }
]
text
 

6.2 数据解读与查询验证

从上面的缓存数据可以快速解读市场情绪:

  • 大盘评分5.3分→“大盘震荡,适当参与”,适合轻仓操作;
  • 上涨2984只,下跌2265只→上涨家数略多,市场偏强;
  • 涨停105只,跌停8只→涨停数量较多,赚钱效应尚可;
  • 涨跌分布:0~2%区间有2241只股票→大部分股票温和上涨,没有极端行情。

查询验证:直接通过Redis客户端执行get stock_public:now:thsHq,即可获取完整数据;在Java代码中,通过redisUtil.get("stock_public:now:thsHq", ThsHqInfoDto.class),可直接将缓存数据转为实体类对象,无需二次解析。

七、核心优化与扩展:让系统更实用、更强大

基础功能实现后,我们可以做2个关键扩展,让这套系统更适配实战需求:

7.1 新增行情预警功能

基于缓存的同花顺数据,添加预警逻辑,比如:当大盘评分<2.5分时发送钉钉提醒,当涨停数量<20只时触发空仓提示。核心扩展代码:

/**
 * 同花顺行情预警(扩展方法)
 * @param thsHqInfoDto 缓存的行情数据
 */
private void thsHqWarn(ThsHqInfoDto thsHqInfoDto) {
    // 1. 大盘风险极大预警(评分<2.5)
    if (thsHqInfoDto.getScore() < 2.5) {
        notifyService.sendDingTalkNotify(
            String.format("【同花顺行情预警】大盘风险极大!当前评分:%s,建议立即空仓", thsHqInfoDto.getScore())
        );
    }
    // 2. 赚钱效应极差预警(涨停<20只)
    if (thsHqInfoDto.getZtNum() < 20) {
        notifyService.sendDingTalkNotify(
            String.format("【同花顺行情预警】赚钱效应极差!当前涨停:%s只,建议观望", thsHqInfoDto.getZtNum())
        );
    }
}
text
 

7.2 对接前端可视化展示

将Redis中的数据通过接口暴露,对接前端ECharts等图表库,实现:大盘评分趋势图、涨跌分布柱状图、涨跌停数量走势图,直观展示市场情绪变化。前端直接调用接口获取ThsHqInfoDto对象,利用其自带的ztfbMessageDataMap等字段,可快速渲染图表。

八、系列文章预告

本文完成了量化系统的“市场情绪数据层”搭建,通过抓取同花顺核心行情数据并缓存到Redis,我们的量化系统终于既有了“指数方向”,又有了“情绪强度”,为后续策略实战打下了坚实的基础!

下一篇文章,我们将获取 单个股票的 日K线图和分钟K线图! 用于查看某个股票的特性信息。

最后,留一个思考问题:如果需要让行情抓取支持多账号轮换Cookie(避免单账号被封),你会如何修改getBackTestHeaderMap方法?欢迎在评论区交流你的解决方案~

Java量化系列(三):实现东方财富历史日K线数据同步_supermind 能每天将k线数据存到指定的数据库吗-CSDN博客

在前两篇文章中,我们先后完成了股票基础列表同步和每日日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 数据表:

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

StockHistoryDo:

 
  1. @Data
  2. @EqualsAndHashCode(callSuper = false)
  3. @TableName("stock_history_30")
  4. public class StockHistoryDo implements Serializable {
  5.  
  6. private static final long serialVersionUID = 1L;
  7.  
  8. /**
  9. * id自增
  10. */
  11. @TableId(value = "id", type = IdType.AUTO)
  12. private Integer id;
  13.  
  14. /**
  15. * 股票的编码
  16. */
  17. @TableField("code")
  18. private String code;
  19.  
  20. /**
  21. * 股票的名称
  22. */
  23. @TableField("name")
  24. private String name;
  25.  
  26. /**
  27. * 当天的日期不包括周六周天
  28. */
  29. @TableField("curr_date")
  30. private Date currDate;
  31.  
  32. /**
  33. * 最低价格
  34. */
  35. @TableField("lowest_price")
  36. private BigDecimal lowestPrice;
  37.  
  38. /**
  39. * 开盘价
  40. */
  41. @TableField("opening_price")
  42. private BigDecimal openingPrice;
  43.  
  44. /**
  45. * 前收盘
  46. */
  47. @TableField("yesClosing_price")
  48. private BigDecimal yesClosingPrice;
  49.  
  50. /**
  51. * 涨跌额
  52. */
  53. @TableField("amplitude")
  54. private BigDecimal amplitude;
  55.  
  56. /**
  57. * 涨跌幅
  58. */
  59. @TableField("amplitude_proportion")
  60. private BigDecimal amplitudeProportion;
  61.  
  62. /**
  63. * 成交量
  64. */
  65. @TableField("trading_volume")
  66. private BigDecimal tradingVolume;
  67.  
  68. /**
  69. * 成交金额
  70. */
  71. @TableField("trading_value")
  72. private BigDecimal tradingValue;
  73.  
  74. /**
  75. * 收盘价
  76. */
  77. @TableField("closing_price")
  78. private BigDecimal closingPrice;
  79.  
  80. /**
  81. * 最高价格
  82. */
  83. @TableField("highest_price")
  84. private BigDecimal highestPrice;
  85.  
  86.  
  87. /**
  88. * 是否涨停 1为涨停 0为不涨停
  89. */
  90. @TableField("zt")
  91. private Integer zt;
  92.  
  93.  
  94. /**
  95. * 最低价所处的时间
  96. */
  97. @TableField("lowest_time")
  98. private Date lowestTime;
  99.  
  100.  
  101. /**
  102. * 最高价所处的时间
  103. */
  104. @TableField("highest_time")
  105. private Date highestTime;
  106.  
  107.  
  108. /**
  109. * 开盘价比例
  110. */
  111. @TableField("open_percent")
  112. private BigDecimal openPercent;
  113.  
  114. /**
  115. * 最低价比例
  116. */
  117. @TableField("lowest_percent")
  118. private BigDecimal lowestPercent;
  119.  
  120.  
  121. /**
  122. * 最高价比例
  123. */
  124. @TableField("highest_percent")
  125. private BigDecimal highestPercent;
  126.  
  127.  
  128.  
  129. /**
  130. * (最高点- 最低点) / 昨日收盘价 *100
  131. */
  132. @TableField("tProportion")
  133. private BigDecimal tProportion;
  134.  
  135. /**
  136. * 外盘数量
  137. */
  138. @TableField("out_dish")
  139. private Integer outDish;
  140.  
  141. /**
  142. * 内盘数量
  143. */
  144. @TableField("inner_dish")
  145. private Integer innerDish;
  146. /**
  147. * 换手率
  148. */
  149. @TableField("changing_proportion")
  150. private BigDecimal changingProportion;
  151. /**
  152. * 量比
  153. */
  154. @TableField("than")
  155. private BigDecimal than;
  156.  
  157. /**
  158. * 均价
  159. */
  160. @TableField("avg_price")
  161. private BigDecimal avgPrice;
  162.  
  163.  
  164. /**
  165. * 市值,亿单位
  166. */
  167. @TableField("market")
  168. private BigDecimal market;
  169.  
  170.  
  171. /**
  172. * 流通市值,亿单位
  173. */
  174. @TableField("lt_market")
  175. private BigDecimal ltMarket;
  176.  
  177.  
  178. /**
  179. * 静态市盈率
  180. */
  181. @TableField("static_price_ratio")
  182. private BigDecimal staticPriceRatio;
  183.  
  184. /**
  185. * 动态市盈率
  186. */
  187. @TableField("dynamic_price_ratio")
  188. private BigDecimal dynamicPriceRatio;
  189.  
  190. /**
  191. * TTM 市盈率
  192. */
  193. @TableField("ttm_price_ratio")
  194. private BigDecimal ttmPriceRatio;
  195.  
  196. /**
  197. * 买的 前五手
  198. */
  199. @TableField("buy_hand")
  200. private Integer buyHand;
  201.  
  202. /**
  203. * 卖的 前五手
  204. */
  205. @TableField("sell_hand")
  206. private Integer sellHand;
  207.  
  208. /**
  209. * 委比
  210. */
  211. @TableField("appoint_than")
  212. private String appointThan;
  213.  
  214.  
  215. /**
  216. * 1为正常 0为删除
  217. */
  218. @TableField("flag")
  219. private Integer flag;
  220.  
  221.  
  222. @TableField(exist = false)
  223. private String webUrl;
  224.  
  225. @TableField(exist = false)
  226. private String aiSendMessage;
  227.  
  228. public String getAiSendMessage() {
  229. return DateUtil.format(currDate, Const.SIMPLE_DATE_FORMAT) + " " +openingPrice + ","+ highestPrice + "," + lowestPrice + ","+ closingPrice + "," + tradingVolume;
  230. }
  231. }
 
 

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

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

核心代码实现:

 
  1. @RestController
  2. @RequestMapping("/stock/history")
  3. public class StockHistoryController {
  4.  
  5. @Resource
  6. private CrawlerStockService crawlerStockService;
  7. @Resource
  8. private StockCodeHelper stockCodeHelper;
  9. @Resource
  10. private StockHistoryAssembler stockHistoryAssembler;
  11. @Resource
  12. private StockHistoryDomainService stockHistoryDomainService;
  13.  
  14. @Operation(description = "同步指定股票历史K线数据")
  15. @PostMapping("/asyncKData")
  16. public OutputResult asyncKData() {
  17. // 示例:同步股票001318的历史K线数据(可扩展为接收前端传入的股票代码列表)
  18. String code = "001318";
  19. log.info("开始同步股票 {} 的历史K线数据", code);
  20.  
  21. try {
  22. // 1. 股票代码标准化转换(适配东方财富接口格式:0013180.001318
  23. String standardCode = stockCodeHelper.convertCode(code);
  24. // 2. 调用服务获取历史K线数据(klt=101代表日K,beg=20251010代表起始时间)
  25. OutputResult<List<TxStockHistoryInfo>> dataResult = crawlerStockService.kDataList(standardCode, 101, "20251010");
  26. List<TxStockHistoryInfo> kDataList = dataResult.getData();
  27.  
  28. if (CollUtil.isEmpty(kDataList)) {
  29. log.info("未获取到股票 {} 的历史K线数据", code);
  30. return OutputResult.buildSucc("未获取到历史K线数据");
  31. }
  32. log.info("成功获取股票 {} 的历史K线数据,共 {} 条", code, kDataList.size());
  33.  
  34. // 3. DTO转DO:将TxStockHistoryInfo转换为StockHistoryDo(适配数据库表结构)
  35. List<StockHistoryDo> stockHistoryDos = new ArrayList<>();
  36. for (TxStockHistoryInfo txStockHistoryInfo : kDataList) {
  37. StockHistoryDo stockHistoryDo = stockHistoryAssembler.txInfoToDo(txStockHistoryInfo);
  38. // 设置交易日期(从DTO的日期对象中获取)
  39. stockHistoryDo.setCurrDate(txStockHistoryInfo.getCurrDateObj());
  40. stockHistoryDos.add(stockHistoryDo);
  41. }
  42.  
  43. // 4. 批量保存到Mysql(批次大小100,避免SQL过长)
  44. stockHistoryDomainService.saveBatch(stockHistoryDos, 100);
  45. log.info("股票 {} 历史K线数据同步并保存完成", code);
  46. return OutputResult.buildSucc("历史K线数据同步成功");
  47. } catch (Exception e) {
  48. log.error("股票 {} 历史K线数据同步失败", code, e);
  49. return OutputResult.buildFail("历史K线数据同步失败:" + e.getMessage());
  50. }
  51. }
  52. }
 
 

关键注意点:

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

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

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

核心代码实现:

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

关键补充说明:

  • StockCodeType枚举类:需自定义该枚举,通过股票代码前缀判断市场类型(如60开头→上海SH,00开头→深圳SZ,30开头→创业板SZ等);
 
  1. public enum StockCodeType {
  2. SH(1, "上海"),
  3. SZ(2, "深圳"),
  4. CY(3, "创业板"),
  5. BJ(4, "北京板"),
  6. OTHER(5, "未知"),
  7. ;
  8.  
  9. private Integer code;
  10.  
  11. private String desc;
  12.  
  13. private StockCodeType(Integer code, String desc) {
  14. this.code = code;
  15. this.desc = desc;
  16. }
  17.  
  18. /**
  19. * 获取交易的方法
  20. *
  21. * @param code
  22. * @return
  23. */
  24. public static StockCodeType getTypeByCode(Integer code) {
  25. if (code == null) {
  26. return null;
  27. }
  28. for (StockCodeType configCodeType : StockCodeType.values()) {
  29. if (configCodeType.code.equals(code)) {
  30. return configCodeType;
  31. }
  32. }
  33. return null;
  34. }
  35.  
  36. /**
  37. * 获取交易的方法
  38. *
  39. * @param code
  40. * @return
  41. */
  42. public static StockCodeType getTypeByStockCode(String code) {
  43. // 如果以 60 开头
  44. if (code.startsWith("68")) {
  45. return StockCodeType.BJ;
  46. } else if (code.startsWith("6")) {
  47. return StockCodeType.SH;
  48. } else if (code.startsWith("0")) {
  49. return StockCodeType.SZ;
  50. } else if (code.startsWith("1")) {
  51. return StockCodeType.OTHER;
  52. } else if (code.startsWith("5")) {
  53. return StockCodeType.OTHER;
  54. }else if (code.startsWith("3")) {
  55. return StockCodeType.CY;
  56. } else if (code.startsWith("83")) {
  57. return StockCodeType.BJ;
  58. } else {
  59. return StockCodeType.OTHER;
  60. }
  61. }
  62.  
  63. public static Boolean isTradeType(String code) {
  64. // 如果以 60 开头
  65. StockCodeType stockCodeType = getTypeByStockCode(code);
  66. return stockCodeType.equals(StockCodeType.SH) || stockCodeType.equals(StockCodeType.SZ)
  67. || stockCodeType.equals(StockCodeType.CY);
  68. }
  69.  
  70. public Integer getCode() {
  71. return code;
  72. }
  73.  
  74. public String getDesc() {
  75. return desc;
  76. }
  77. }
 
 

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

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

核心代码实现:

 
  1. @Service
  2. public class CrawlerStockServiceImpl implements CrawlerStockService {
  3.  
  4. @Resource
  5. private CrawlerService crawlerService;
  6.  
  7. @Override
  8. public OutputResult<List<TxStockHistoryInfo>> kDataList(String code, Integer type, String beg) {
  9. try {
  10. // 调用爬虫服务获取历史K线数据
  11. List<TxStockHistoryInfo> kDataList = crawlerService.kDataList(code, type, beg);
  12. return OutputResult.buildSucc(kDataList);
  13. } catch (Exception e) {
  14. log.error("获取股票 {} 历史K线数据失败(类型:{},起始时间:{})", code, type, beg, e);
  15. return OutputResult.buildFail("获取历史K线数据失败");
  16. }
  17. }
  18. }
  19.  
  20. @Service
  21. public class CrawlerServiceImpl implements CrawlerService {
  22.  
  23. // JSONP回调函数名(接口固定返回该回调包裹的JSON数据,需固定此值)
  24. private static final String KDATA_CB = "jQuery35105361642636114103_1690247737667";
  25.  
  26. @Override
  27. public List<TxStockHistoryInfo> kDataList(String code, Integer type, String beg) {
  28. try {
  29. // 1. 拼接历史K线接口URL(替换cb、secid、klt、beg参数)
  30. String kdataUrl = "https://push2his.eastmoney.com/api/qt/stock/kline/get?cb={0}&secid={1}" +
  31. "&ut=fa5fd1943c7b386f172d6893dbfba10b&fields1=f1,f2,f3,f4,f5,f6&fields2=f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61&klt={2}" +
  32. "&fqt=1&beg={3}&end=20500101&lmt=800&_=";
  33. String url = MessageFormat.format(kdataUrl, KDATA_CB, code, type, beg);
  34.  
  35. // 2. 构建请求头:模拟浏览器请求,避免被接口拦截
  36. Map<String, String> header = new HashMap<>();
  37. header.put("Host", "push2his.eastmoney.com");
  38. header.put("Cookie", "qgqp_b_id=ec2d8007963808c47e3e13c6ab114c63; st_nvi=XXXXXXXqxg-2"); // 实际使用时替换为有效Cookie
  39. 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");
  40.  
  41. // 3. 发送GET请求:获取JSONP格式的响应内容(添加时间戳参数避免缓存)
  42. String content = HttpUtil.sendGet(HttpClientConfig.proxyNoUseCloseableHttpClient(), url + MyDateUtil.getTimezone());
  43. log.info("东方财富历史K线接口返回内容:{}", content);
  44.  
  45. // 4. JSONP格式清洗:剥离回调函数前缀(KDATA_CB + "(")和后缀(");"
  46. content = content.substring(KDATA_CB.length() + 1); // 去掉前缀:KDATA_CB(
  47. content = content.substring(0, content.length() - 2); // 去掉后缀:);
  48.  
  49. // 5. 调用解析服务,将清洗后的JSON数据转换为TxStockHistoryInfo列表
  50. List<TxStockHistoryInfo> txStockHistoryInfos = stockInfoParser.kDataList(content);
  51. return CollUtil.isEmpty(txStockHistoryInfos) ? Collections.emptyList() : txStockHistoryInfos;
  52. } catch (Exception e) {
  53. log.error("同步股票 {} 历史K线数据失败(类型:{},起始时间:{})", code, type, beg, e);
  54. // 异常统计:自定义埋点,便于监控接口可用性
  55. globalWebExceptionHandlerAspect.addException();
  56. return null;
  57. }
  58. }
  59. }
 
 

关键注意点:

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

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

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

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

核心代码实现:

 
  1. @Service
  2. public class StockInfoParserImpl implements StockInfoParser {
  3.  
  4. @Override
  5. public List<TxStockHistoryInfo> kDataList(String content) {
  6. // 1. 将清洗后的JSON字符串转换为JSONObject
  7. JSONObject jsonObject = JSONObject.parseObject(content);
  8. // 2. 提取data字段(核心数据所在字段)
  9. JSONObject data = jsonObject.getJSONObject("data");
  10. if (ObjectUtils.isEmpty(data)) {
  11. log.info("历史K线数据解析:data字段为空");
  12. return Collections.emptyList();
  13. }
  14. // 3. 提取股票基本信息:代码和名称
  15. String code = data.getString("code");
  16. String name = data.getString("name");
  17. // 4. 提取klines字段(K线数据数组,每条为单根K线的字符串)
  18. JSONArray jsonArray = data.getJSONArray("klines");
  19. if (jsonArray.size() <= 0) {
  20. log.info("股票 {} 未获取到历史K线数据", code);
  21. return Collections.emptyList();
  22. }
  23.  
  24. // 5. 遍历解析每条K线数据
  25. List<TxStockHistoryInfo> result = new ArrayList<>(jsonArray.size());
  26. String finalCode = code;
  27. String finalName = name;
  28. jsonArray.forEach(n -> {
  29. String singleContent = n.toString();
  30. if (StrUtil.isNotBlank(singleContent)) {
  31. // 按逗号拆分单根K线数据(字段顺序:日期,开盘价,收盘价,最高价,最低价,成交量,成交额,涨跌额,涨跌幅,振幅,换手率...)
  32. String[] splitArr = singleContent.split(Const.JOB_PARAM_SPLIT); // Const.JOB_PARAM_SPLIT为逗号分隔符
  33. TxStockHistoryInfo txStockHistoryInfo = new TxStockHistoryInfo();
  34. // 基础字段映射
  35. txStockHistoryInfo.setCode(finalCode); // 股票代码
  36. txStockHistoryInfo.setName(finalName); // 股票名称
  37. txStockHistoryInfo.setCurrDate(splitArr[0]); // 交易日期(格式:yyyy-MM-dd)
  38. txStockHistoryInfo.setCurrDateObj(DateUtil.parse(splitArr[0], Const.SIMPLE_DATE_FORMAT)); // 转换为日期对象
  39. // 价格字段映射(BigDecimal类型,避免精度丢失)
  40. txStockHistoryInfo.setOpeningPrice(new BigDecimal(splitArr[1])); // 开盘价
  41. txStockHistoryInfo.setClosingPrice(new BigDecimal(splitArr[2])); // 收盘价
  42. txStockHistoryInfo.setHighestPrice(new BigDecimal(splitArr[3])); // 最高价
  43. txStockHistoryInfo.setLowestPrice(new BigDecimal(splitArr[4])); // 最低价
  44. // 成交量(Long类型)、成交额(BigDecimal类型)
  45. txStockHistoryInfo.setTradingVolume(Long.parseLong(splitArr[5])); // 成交量(单位:股)
  46. txStockHistoryInfo.setTradingValue(new BigDecimal(splitArr[6])); // 成交额(单位:元)
  47. // 衍生指标映射
  48. txStockHistoryInfo.setZf(new BigDecimal(splitArr[7])); // 涨跌额
  49. txStockHistoryInfo.setAmplitudeProportion(new BigDecimal(splitArr[8])); // 涨跌幅(%)
  50. txStockHistoryInfo.setAmplitude(new BigDecimal(splitArr[9])); // 振幅(%)
  51. txStockHistoryInfo.setChangingProportion(new BigDecimal(splitArr[10])); // 换手率(%)
  52.  
  53. result.add(txStockHistoryInfo);
  54. }
  55. });
  56. return result;
  57. }
  58. }
 
 

关键注意点:

  • 字段顺序确认: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线数据时,如何设计线程池参数(核心线程数、最大线程数、队列大小),才能在提升同步效率的同时,避免触发东方财富接口的限流机制?欢迎在评论区交流~

Java量化系列(四):实现自选股票维护功能_自选股票 接口-CSDN博客

在前三篇文章中,我们已完成量化系统“数据层”的完整搭建——包括股票基础列表同步、每日日K增量同步、历史K线存量同步。有了完备的股票数据后,接下来需要面向用户层面构建核心交互功能:自选股票维护。毕竟对于量化交易者而言,关注的股票往往是少数几只,自选功能能帮助用户快速聚焦核心标的,便捷查看其数据与策略表现。

本文作为Java量化系列的第四篇,将聚焦“自选股票维护”核心模块,基于SpringBoot 3.3.8 + Mybatis-Plus + Mysql8.0技术栈,实现自选股票的查询、添加、删除、编辑笔记四大基础功能。同时涵盖表结构设计、同样适合三年以上Java开发经验,希望完善量化系统用户交互层的工程师参考。

一、核心需求与表结构设计

自选股票维护的核心诉求是“用户隔离”“操作便捷”“数据一致”,结合量化系统的用户使用场景,我们先明确需求边界,再设计适配的表结构。

1.1 核心需求边界

  • 用户隔离:不同用户的自选股票独立存储,通过user_id字段区分,避免数据混淆;
  • 基础操作:支持四大核心功能——查询(按关键词模糊搜索、分页)、添加(校验股票有效性、避免重复)、删除(按股票代码移除)、编辑笔记(记录标的关注原因、策略要点等);
  • 状态管控:支持自选股票的“启用/禁用”状态(status字段)和“逻辑删除”(flag字段),避免物理删除导致数据丢失;
  • 异常兼容:添加时校验股票是否存在,删除时清理关联缓存,编辑时校验必填字段,确保操作合法性。

1.2 表结构设计:stock_selected

基于需求设计自选股票表stock_selected,兼顾用户隔离、状态管控和扩展需求,具体SQL如下:

 
  1. CREATE TABLE `stock_selected` (
  2. `id` int NOT NULL AUTO_INCREMENT COMMENT '主键自增',
  3. `stock_code` varchar(8) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL COMMENT '股票的编号',
  4. `stock_name` varchar(100) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '股票的名称',
  5. `user_id` int DEFAULT NULL COMMENT '用户的id',
  6. `create_time` timestamp NULL DEFAULT NULL COMMENT '创建时间',
  7. `code_notes` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '笔记',
  8. `status` int NOT NULL COMMENT '状态 0为禁用 1为启用',
  9. `flag` int DEFAULT NULL COMMENT '1是正常0为删除',
  10. PRIMARY KEY (`id`) USING BTREE
  11. ) ENGINE=InnoDB AUTO_INCREMENT=520 DEFAULT CHARSET=utf8mb3 ROW_FORMAT=DYNAMIC COMMENT='股票自选表,是用户自己选择的';
 
 

关键字段说明:

  • user_id:核心隔离字段,关联用户表主键,确保不同用户的自选数据独立;
  • stock_code:股票编号(如001318、600000),关联股票基础表的code字段;
  • code_notes:笔记字段,支持用户记录关注该股票的原因(如“新能源赛道龙头”“均线策略标的”);
  • status:状态字段(0禁用/1启用),支持用户隐藏部分自选股票但不删除;
  • flag:逻辑删除标识(1正常/0删除),避免物理删除导致历史数据无法追溯;
  • create_time:创建时间,便于用户查看自选股票的添加时间线。

1.3 核心对象映射:DO

遵循“分层设计”原则,定义核心对象完成请求参数接收、数据库交互、响应结果返回的流转,核心对象如下:

1.3.1 数据库对象:StockSelectedDo

stock_selected表字段一一对应,用于Mybatis-Plus数据库交互,核心代码:

 
  1. @Data
  2. @EqualsAndHashCode(callSuper = false)
  3. @TableName("stock_selected")
  4. public class StockSelectedDo implements Serializable {
  5.  
  6. private static final long serialVersionUID = 1L;
  7.  
  8. /** 主键自增 */
  9. @TableId(value = "id", type = IdType.AUTO)
  10. private Integer id;
  11.  
  12. /** 股票的编号 */
  13. @TableField("stock_code")
  14. private String stockCode;
  15.  
  16. /** 股票的名称 */
  17. @TableField("stock_name")
  18. private String stockName;
  19.  
  20. /** 用户的id */
  21. @TableField("user_id")
  22. private Integer userId;
  23.  
  24. /** 创建时间 */
  25. @TableField("create_time")
  26. private Date createTime;
  27.  
  28. /** 笔记(对应表中code_notes字段) */
  29. @TableField("code_notes")
  30. private String notes;
  31.  
  32. /** 是否启用,禁用 1为启用,0为禁用 */
  33. @TableField("status")
  34. private Integer status;
  35.  
  36. /** 是否删除 1为正常 0为删除(表中flag字段定义) */
  37. @TableField("flag")
  38. private Integer flag;
  39. }
 
 

关键说明:通过@TableName关联数据库表,@TableId指定主键策略(自增),@TableField关联表字段(notes对应表中code_notes,需显式指定字段名)。

1.3.2 请求对象:StockSelectedRo

接收前端请求参数(如查询条件、添加信息、编辑笔记内容),核心字段包括userId(当前登录用户ID)、stockCode(股票代码)、notes(笔记)等,按需扩展查询关键词字段。

1.3.3 响应对象:StockSelectedVo

返回前端展示数据,在StockSelectedDo基础上屏蔽数据库字段(如flag逻辑删除标识),仅暴露用户需查看的字段(idstockCodestockNamenotesstatuscreateTime)。

二、核心流程设计:四大功能的完整链路

主要是对单表的进行维护, 故只列出关键的逻辑。

自选股票维护的四大核心功能,均遵循“请求参数校验 → 业务逻辑处理 → 数据库交互 → 结果返回”的链路,不同功能的核心差异在于业务逻辑校验环节(如添加需校验股票存在性和重复性,删除需清理缓存)。整体流程概括如下:

前端请求 → 控制器接收参数 → 拦截器获取当前用户ID → 业务层校验参数合法性 → 数据层交互(查/增/删/改) → 响应结果返回
 

三、核心实现:分步拆解四大功能代码

3.1 控制器层:接口定义与参数预处理

首先定义自选股票维护的核心接口,采用RESTful风格,通过POST请求接收参数,同时在控制器层完成基础参数校验(如股票代码非空)和当前用户ID注入(通过getUserId()方法从上下文获取,需集成用户登录认证功能)。

核心代码实现:

 
  1. @RestController
  2. @RequestMapping("/stock/selected")
  3. public class StockSelectedController {
  4.  
  5. @Resource
  6. private StockSelectedBusiness stockSelectedBusiness;
  7.  
  8. /**
  9. * 1. 查询用户自选股票(支持分页、关键词模糊搜索)
  10. * @param stockSelectedRo 包含查询条件(股票代码、名称、状态等)的请求对象
  11. * @return 分页后的自选股票列表
  12. */
  13. @Operation(summary = "查询用户自选股票")
  14. @PostMapping("/list")
  15. public OutputResult<PageResponse<StockSelectedVo>> list(@RequestBody StockSelectedRo stockSelectedRo) {
  16. // 注入当前登录用户ID,确保数据隔离
  17. stockSelectedRo.setUserId(getUserId());
  18. return stockSelectedBusiness.listSelected(stockSelectedRo);
  19. }
  20.  
  21. /**
  22. * 2. 添加股票到自选表
  23. * @param stockSelectedRo 包含股票代码的请求对象
  24. * @return 添加结果(成功/失败提示)
  25. */
  26. @Operation(summary = "添加到自选表")
  27. @PostMapping("/add")
  28. public OutputResult add(@RequestBody StockSelectedRo stockSelectedRo){
  29. // 基础校验:股票代码不能为空
  30. if (!StrUtil.isNotBlank(stockSelectedRo.getStockCode())){
  31. return OutputResult.buildAlert(ResultCode.STOCK_CODE_IS_EMPTY);
  32. }
  33. // 注入当前登录用户ID
  34. stockSelectedRo.setUserId(getUserId());
  35. return stockSelectedBusiness.add(stockSelectedRo);
  36. }
  37.  
  38. /**
  39. * 3. 根据股票代码删除自选股票(股票页面专用)
  40. * @param codeRo 包含股票代码的请求对象
  41. * @return 删除结果(成功/失败提示)
  42. */
  43. @Operation(summary = "根据股票code进行移除,股票页面使用")
  44. @PostMapping("/deleteByCode")
  45. public OutputResult deleteByCode(@RequestBody CodeRo codeRo){
  46. // 基础校验:股票代码不能为空
  47. if (!StrUtil.isNotBlank(codeRo.getCode())){
  48. return OutputResult.buildAlert(ResultCode.STOCK_CODE_IS_EMPTY);
  49. }
  50. // 转换为自选股票请求对象,注入当前登录用户ID
  51. StockSelectedRo stockSelectedRo = new StockSelectedRo();
  52. stockSelectedRo.setUserId(getUserId());
  53. stockSelectedRo.setStockCode(codeRo.getCode());
  54. return stockSelectedBusiness.deleteByCode(stockSelectedRo);
  55. }
  56.  
  57. /**
  58. * 4. 编辑自选股票笔记
  59. * @param stockSelectedRo 包含记录ID/股票代码、笔记内容的请求对象
  60. * @return 编辑结果(成功/失败提示)
  61. */
  62. @Operation(summary = "根据自选记录,编辑笔记")
  63. @PostMapping("/editNotes")
  64. public OutputResult editNotes(@RequestBody StockSelectedRo stockSelectedRo){
  65. // 注入当前登录用户ID
  66. stockSelectedRo.setUserId(getUserId());
  67. // 基础校验:必须指定记录ID或股票代码(定位要编辑的记录),笔记内容不能为空
  68. if (stockSelectedRo.getId() == null && !StrUtil.isNotBlank(stockSelectedRo.getStockCode())) {
  69. return OutputResult.buildAlert(ResultCode.ID_IS_EMPTY);
  70. }
  71. if (!StrUtil.isNotBlank(stockSelectedRo.getNotes())) {
  72. return OutputResult.buildAlert(ResultCode.STOCK_SELECTED_NOTES_EMPTY);
  73. }
  74. return stockSelectedBusiness.editNotes(stockSelectedRo);
  75. }
  76.  
  77. /**
  78. * 从上下文获取当前登录用户ID
  79. */
  80. private Integer getUserId() {
  81. Integer userId = ThreadLocalUtils.getUserId();
  82. if (userId != null) {
  83. return userId;
  84. }
  85. return 1;
  86. }
  87. }
 
 

关键注意点:

  • 用户ID注入:通过getUserId()方法从用户上下文获取当前登录用户ID,确保不同用户的自选数据隔离,这是自选功能的核心设计点;
  • 基础参数校验:在控制器层完成简单的非空校验(如股票代码、笔记内容),避免无效请求进入业务层,提升系统效率;
  • 接口文档:通过@Operation注解生成接口文档(集成Knife4j/Swagger),便于前后端联调。

3.2 业务层:核心逻辑实现(重点)

业务层是自选功能的核心,负责完成参数合法性校验、关联数据查询(如校验股票是否存在)、业务规则执行(如避免重复添加)、缓存处理等核心逻辑。下面按四大功能分别拆解:

3.2.1 功能一:查询用户自选股票

核心逻辑:根据当前用户ID,结合查询条件(如股票代码/名称关键词、状态)进行分页查询,支持模糊搜索(通过股票代码或名称匹配关键词)。

核心代码实现:

​ 根据 股票编码 或者 股票名称 进行查询

3.2.2 功能二:添加股票到自选表

核心逻辑:添加前需完成三重校验——① 股票是否存在(通过股票代码/名称/完整代码查询基础股票表);② 该股票是否已添加到当前用户的自选表(避免重复);③ 自选股票数量是否超限(如最多添加100只)。校验通过后,插入自选表并默认设置状态为“启用”(status=1)。

核心代码实现:

 
  1. @Service
  2. public class StockSelectedBusinessImpl implements StockSelectedBusiness {
  3.  
  4. @Resource
  5. private StockSelectedService stockSelectedService;
  6. @Resource
  7. private StockService stockService;
  8.  
  9. @Override
  10. public OutputResult add(StockSelectedRo stockSelectedRo) {
  11. try {
  12. // 1. 校验1:查询股票是否存在(支持通过股票代码、完整代码、名称查询)
  13. StockDto stockDto = stockService.getByCodeOrNameOrFullCode(stockSelectedRo.getStockCode());
  14. if (null == stockDto) {
  15. // 股票不存在,返回错误提示
  16. return OutputResult.buildAlert(ResultCode.STOCK_CODE_NO_EXIST);
  17. }
  18.  
  19. // 2. 统一股票代码格式(避免因完整代码/简称导致的重复)
  20. stockSelectedRo.setStockCode(stockDto.getCode());
  21.  
  22. // 3. 校验2:该股票是否已添加到当前用户的自选表(避免重复添加)
  23. OutputResult addValidateResult = stockSelectedService.validateAdd(stockSelectedRo, 100);
  24. if (!addValidateResult.getSuccess()) {
  25. return addValidateResult;
  26. }
  27.  
  28. // 4. 校验通过,添加到自选表(默认状态:启用status=1,正常flag=1,创建时间为当前时间)
  29. stockSelectedService.add(stockSelectedRo, stockDto.getName());
  30.  
  31. // 5. 返回添加成功提示(包含股票代码和名称)
  32. return OutputResult.buildSucc(stockDto.getCode() + "(名称:" + stockDto.getName() + ")添加到自选成功");
  33. } catch (Exception e) {
  34. log.error("用户 {} 添加自选股票 {} 失败", stockSelectedRo.getUserId(), stockSelectedRo.getStockCode(), e);
  35. return OutputResult.buildFail("添加自选股票失败:" + e.getMessage());
  36. }
  37. }
  38. }
 
 

关键补充说明:

  • 股票存在性校验:通过stockService.getByCodeOrNameOrFullCode()方法查询基础股票表,支持股票代码、完整代码(如0.001318)、股票名称三种查询方式,提升用户操作便捷性;
  • 重复添加校验:stockSelectedService.validateAdd()方法内部通过“user_id + stock_code”组合查询,判断该股票是否已在当前用户的自选表中(且状态为正常);
  • 数量限制校验:validateAdd()方法第二个参数“100”为自选股票数量上限,避免用户添加过多标的导致查询效率下降。

3.2.3 功能三:根据股票代码删除自选股票

核心逻辑:删除前需查询股票是否存在,然后执行逻辑删除(更新flag字段为0,而非物理删除),同时清理该股票的价格缓存(如Redis中存储的实时价格),避免缓存数据不一致。

核心代码实现:

 
  1. @Service
  2. public class StockSelectedBusinessImpl implements StockSelectedBusiness {
  3.  
  4. @Resource
  5. private StockSelectedService stockSelectedService;
  6. @Resource
  7. private StockService stockService;
  8. @Resource
  9. private RedisUtil redisUtil;
  10.  
  11. @Override
  12. public OutputResult deleteByCode(StockSelectedRo stockSelectedRo) {
  13. try {
  14. // 1. 校验股票是否存在,获取标准股票代码
  15. StockDto stockDto = stockService.getByCodeOrNameOrFullCode(stockSelectedRo.getStockCode());
  16. if (null == stockDto) {
  17. return OutputResult.buildAlert(ResultCode.STOCK_CODE_NO_EXIST);
  18. }
  19. String realCode = stockDto.getCode();
  20. stockSelectedRo.setStockCode(realCode);
  21.  
  22. // 2. 执行逻辑删除(更新flag=0,而非物理删除)
  23. OutputResult deleteResult = stockSelectedService.deleteByCode(stockSelectedRo);
  24. if (!deleteResult.getSuccess()){
  25. return deleteResult;
  26. }
  27.  
  28. // 3. 清理缓存:删除该股票的实时价格缓存(避免缓存残留)
  29. redisUtil.delete(Collections.singleton(Const.STOCK_PRICE + realCode));
  30.  
  31. return OutputResult.buildSucc("删除自选股票 " + realCode + " 成功");
  32. } catch (Exception e) {
  33. log.error("用户 {} 删除自选股票 {} 失败", stockSelectedRo.getUserId(), stockSelectedRo.getStockCode(), e);
  34. return OutputResult.buildFail("删除自选股票失败:" + e.getMessage());
  35. }
  36. }
  37. }
 
 

关键注意点:

  • 逻辑删除:通过更新flag字段为0实现逻辑删除,保留历史数据,便于后续数据统计和恢复;
  • 缓存清理:删除自选股票后,清理Redis中该股票的价格缓存(Const.STOCK_PRICE + 股票代码为缓存key),避免用户已删除自选股票但仍能看到缓存的价格数据,确保数据一致性;
  • 标准代码统一:通过stockService.getByCodeOrNameOrFullCode()获取标准股票代码,避免因用户输入完整代码(如0.001318)导致删除失败。

3.2.4 功能四:编辑自选股票笔记

核心逻辑:通过“记录ID”或“用户ID+股票代码”定位要编辑的自选记录,校验笔记内容非空后,更新notes字段(对应表中code_notes)。

核心代码实现:

修改 code_notes 表字段的信息

关键注意点:

  • 记录定位:支持两种定位方式——通过记录ID(精确)或“用户ID+股票代码”(兼容无ID场景,如股票页面直接编辑),提升操作灵活性;
  • 状态校验:仅允许编辑正常状态(flag=1)的自选记录,避免编辑已删除的记录;
  • 字段映射:注意notes字段对应表中code_notes,需确保StockSelectedDo@TableField注解配置正确。

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

上述实现已满足自选股票维护的基础需求,但在生产环境中需补充以下优化点,确保系统稳定性、效率与用户体验:

  1. 缓存优化:查询用户自选股票列表时,添加Redis缓存(缓存key为USER_SELECTED_STOCK + userId),避免频繁查询数据库;添加/删除/编辑操作后,同步更新缓存(删除旧缓存或更新缓存内容);
  2. 权限控制:集成Spring Security/Shiro等认证框架,确保getUserId()方法能安全获取当前登录用户ID,避免未登录用户访问自选接口;
  3. 参数校验增强:使用JSR-380注解(如@NotBlank@NotNull)替代手动非空校验,结合全局异常处理器统一处理校验失败信息;
  4. 异步处理:对于批量添加/删除自选股票的场景(扩展功能),采用线程池异步处理,提升用户操作体验;
  5. 日志与监控:记录自选股票操作日志(用户ID、操作类型、股票代码、操作时间),便于问题排查和用户行为分析;集成监控工具(如Prometheus + Grafana)监控接口调用量和失败率。

五、系列文章预告

本文完成了量化系统“用户交互层”的核心模块——自选股票维护,实现了用户对核心标的的便捷管理。下一篇文章将聚焦 “获取股票的实时价格”,为后续策略回测功能铺路。

最后,留一个思考问题:在自选股票列表查询场景中,如何设计缓存策略(如缓存过期时间、更新机制),才能在保证查询效率的同时,避免缓存与数据库数据不一致?欢迎在评论区交流~

 

posted @ 2026-01-15 16:01  CharyGao  阅读(77)  评论(1)    收藏  举报