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;
}
这里重点解读几个核心字段的设计逻辑(避免冗余,聚焦关键):
code与fullCode:code为股票简称(如600036),fullCode为带交易所前缀的完整代码(如SH600036),便于后续区分不同交易所的股票;exchange:交易所标识(0=深圳、1=上海、2=北京),后续策略可按交易所筛选股票;point:标的类型(0=股票、1=指数、2=ETF、3=可转债),支持后续多类型标的的统一管理;tradeDay:交易规则(0=T+0、1=T+1),为后续策略的交易逻辑提供基础依据;flag:删除标识(1=正常、2=删除),采用逻辑删除而非物理删除,避免历史数据丢失影响回测。
补充说明:实体类中can_use(是否可用)、can_rong(是否融资融券)等字段,是为后续策略筛选标的预留的扩展字段,本次核心聚焦列表更新,暂不展开其业务逻辑。
三、核心实现:从接口爬取到数据保存的完整流程
本次功能的核心流程为:分页调用股票接口 → 解析返回数据 → 数据格式转换 → 批量保存到数据库,下面逐步拆解每个环节的实现细节与关键注意点。
3.1 分页爬取:接口调用与防爬处理 (重点)
我们选用东方财富的公开接口 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();
}
}
关键注意点:
- 防爬处理:通过
ThreadUtil.safeSleep(5000)设置5秒爬取间隔,避免高频请求触发接口限流;同时封装buildDfHeaderMap()方法添加请求头(如User-Agent),模拟浏览器请求; - 熔断降级:添加
@CoolDownCircuit自定义注解(基于Spring AOP实现),避免接口异常时大量重试导致系统资源耗尽; - 分页终止条件:当解析当前页数据为空时,说明已获取所有数据,设置
stopSearch = true终止循环,避免无效请求。
3.2 数据解析:JSON转实体类的核心逻辑 (重点)
接口返回的是JSON格式字符串,核心数据嵌套在data.diff字段中(为Map结构,key为股票唯一标识,value为股票详情)。解析核心逻辑为:先提取data.diff数据,再映射为我们自定义的DownloadStockInfoDTO(数据传输对象)。
返回的格式如下:
{
"rc": 0,
"rt": 6,
"svr": 177617654,
"lt": 1,
"full": 1,
"dlmkts": "",
"data": {
"total": 6808,
"diff": {
"0": {
"f2": 3732,
"f3": -2000,
"f5": 168473,
"f10": 308,
"f12": "300723",
"f13": 0,
"f14": "一品红",
"f15": 3800,
"f16": 3732,
"f17": 3732,
"f18": 4665
},
"1": {
"f2": 12675,
"f3": -1867,
"f5": 49923,
"f10": 212,
"f12": "688109",
"f13": 1,
"f14": "品茗科技",
"f15": 15200,
"f16": 12601,
"f17": 15051,
"f18": 15585
},
"2": {
"f2": 13160,
"f3": -1170,
"f5": 265860,
"f10": 155,
"f12": "688521",
"f13": 1,
"f14": "芯原股份",
"f15": 14188,
"f16": 13160,
"f17": 14150,
"f18": 14904
},
"3": {
"f2": 16206,
"f3": -1134,
"f5": 178236,
"f10": 96,
"f12": "300751",
"f13": 0,
"f14": "迈为股份",
"f15": 17662,
"f16": 16200,
"f17": 17500,
"f18": 18279
},
"4": {
"f2": 17559,
"f3": -1069,
"f5": 123772,
"f10": 77,
"f12": "688195",
"f13": 1,
"f14": "腾景科技",
"f15": 18800,
"f16": 17451,
"f17": 18500,
"f18": 19660
}
}
}
}
解析核心代码实现:
@Override
public List<DownloadStockInfo> parseStockInfoList(String content) throws JsonProcessingException {
JSONObject jsonObject = JSONObject.parseObject(content);
JSONObject data = null;
try {
data = jsonObject.getJSONObject("data");
if (ObjectUtils.isEmpty(data)) {
return Collections.emptyList();
}
} catch (Exception e) {
return Collections.emptyList();
}
if (!data.containsKey("diff")) {
return Collections.emptyList();
}
// 转换为自定义响应对象(DfApiResponse包含data.diff结构)
ObjectMapper objectMapper = new ObjectMapper();
DfApiResponse apiResponse = objectMapper.readValue(content, DfApiResponse.class);
Map<String, DfDiffItem> diffMap = apiResponse.getData().getDiff();
if (CollUtil.isEmpty(diffMap)) {
return Collections.emptyList();
}
// 映射为DownloadStockInfo列表
List<DownloadStockInfo> result = new ArrayList<>();
diffMap.forEach((key, diffItem) -> {
DownloadStockInfo downloadStockInfo = new DownloadStockInfo();
downloadStockInfo.setCode(diffItem.getF12()); // 股票代码
downloadStockInfo.setName(diffItem.getF14()); // 股票名称
downloadStockInfo.setExchange(diffItem.getF13()); // 交易所标识
// 生成完整股票代码(如SH600036)
downloadStockInfo.setFullCode(StockUtil.getFullCode(downloadStockInfo.getCode()));
// 处理股票可用状态(基于当前价格判断)
Integer price = Integer.parseInt(diffItem.getF2());
downloadStockInfo.setCanUse((price == null || price == 0) ? 0 : 1);
downloadStockInfo.setNowPrice(price); // 当前价格
downloadStockInfo.setHighPrice(Integer.parseInt(diffItem.getF15())); // 当日最高价
downloadStockInfo.setLowerPrice(Integer.parseInt(diffItem.getF16())); // 当日最低价
result.add(downloadStockInfo);
});
return result;
}
关键注意点:
- 字段映射:接口返回的字段以
F+数字命名(如F12=股票代码、F14=股票名称),需要通过文档或抓包确认字段含义,避免映射错误; - 格式转换:接口返回的价格字段为字符串类型,需转换为Integer(这里注意:实际场景中价格可能包含小数,建议用BigDecimal,本文为简化示例用Integer);
- 工具类封装:
StockUtil.getFullCode()为自定义工具方法,根据股票代码前缀(如60开头为上海、00开头为深圳)生成完整代码,便于后续区分。
这儿是:
private static final List<String> shList;
private static final List<String> shThreeList;
private static final List<String> CODES_SH_A = Arrays.asList("600", "601", "603", "605", "688", "689");
private static final List<String> CODES_SH_ETF = Arrays.asList("51", "56", "58");
private static final List<String> CODES_SH_CB = Arrays.asList("100", "110");
private static final List<String> CODES_SZ_A = Arrays.asList("000", "001", "002", "003", "004", "300", "301");
private static final List<String> CODES_SZ_ETF = Collections.singletonList("15");
private static final List<String> CODES_SZ_CB = Arrays.asList("12");
private static final List<String> CODES_BJ_A = Arrays.asList("83", "87", "43");
private static final List<String> CODES_BJ_ETF = Collections.emptyList();
private static final List<String> CODES_BJ_CB = Collections.emptyList();
private static final Map<String,String> bullMap;
static{
shList= Arrays.asList("5","6","9");
shThreeList=Arrays.asList("009","126","110","201","202","203","204");
}
/**
* 根据股票代码获取完整的股票代码(包含交易所标识)
*
* @param stockCode 股票代码
* @return 完整的股票代码(包含交易所标识)
*/
public static String getFullCode(String stockCode){
if (!StrUtil.isNotBlank(stockCode)||stockCode.length()<3) {
return stockCode;
}
String one = stockCode.substring(0, 1);
String three = stockCode.substring(0, 3);
if (shList.contains(one)) {
return ExchangeType.SH.getDesc()+stockCode;
} else {
if (shThreeList.contains(three)) {
return ExchangeType.SH.getDesc()+stockCode;
} else {
return ExchangeType.SZ.getDesc()+stockCode;
}
}
}
3.3 数据保存:批量插入与重复过滤
解析得到DownloadStockInfo列表后,需要先过滤掉已存在的股票(避免重复插入),再转换为StockDo实体类,最后通过Mybatis-Plus的saveBatch方法批量插入数据库。
核心保存逻辑实现:
// 2. 过滤新增股票(排除已存在的代码)
Date now = DateUtil.date();
// 主要代码
List<DownloadStockInfo> downloadStockInfoList = crawlerService.getStockList();
if (CollUtil.isEmpty(downloadStockInfoList)) {
log.error("同步时未获取到股票列表信息");
return OutputResult.buildFail(ResultCode.STOCK_ASYNC_FAIL);
}
List<StockDo> stockList=new ArrayList<>();
downloadStockInfoList.stream().forEach(n -> {
// DTO转DO(通过StockAssembler封装转换逻辑)
StockDo stockDo = stockAssembler.downInfoToDO(n);
// 补充基础字段
stockDo.setCreateUser("async"); // 创建人(异步任务)
stockDo.setCreateTime(now); // 创建时间
stockDo.setFlag(DataFlagType.NORMAL.getCode()); // 正常状态
stockList.add(stockDo);
});
// 3. 批量插入数据库
if (CollUtil.isEmpty(stockList)) {
return OutputResult.buildSucc(ResultCode.STOCK_ASYNC_NO_CHANGE);
}
log.info("本次同步新增股票编码:{}", stockList.stream().map(StockDo::getCode).collect(Collectors.toList()));
// 批量插入(批次大小100,避免SQL过长)
boolean saveBatch = stockDomainService.saveBatch(stockList, 100);
关键注意点:
- 去重逻辑:先查询数据库中正常状态(flag=1)的股票代码列表,过滤掉已存在的股票,避免重复插入导致主键冲突;
- 批量插入优化:Mybatis-Plus的
saveBatch方法支持指定批次大小(这里设为100),避免一次性插入过多数据导致SQL语句过长; - 字段补充:
createUser设为“async”,标识该数据由异步任务同步,便于后续问题排查。
四、核心优化点与后续扩展
本次实现的股票列表更新功能,已满足基础量化场景需求,但在生产环境中还需补充以下优化点:
- 可以增加 查询股票列表,包括关键词查询的功能
- 异步化处理:通过Spring Task 定时执行股票列表同步任务(如每日开盘前执行),避免阻塞主线程;
- 数据更新逻辑:当前仅支持新增股票,后续需补充更新逻辑(如股票名称变更、交易规则调整),通过
code作为唯一键执行“新增或更新”操作; - 异常重试机制:对爬取失败的页码,添加重试机制(结合指数退避策略),避免因网络波动导致数据缺失;
- 监控告警:添加自定义埋点,监控爬取成功率、数据量变化等指标,异常时通过邮件或钉钉告警;
- 代理池支持:当单IP爬取受限的,可集成代理池工具,动态切换IP地址。
后续系列文章预告: 下一篇将聚焦“股票当前日K线数据的爬取与存储”,基于本次实现的股票列表,进一步完善基础数据层,为后续策略回测提供数据支撑。
评论区或者私信联系博主,可以领取 目前 stock 表的全部的数据,减少你手动同步的步骤。
Java量化系列(二):基于SpringBoot实现股票日K数据每日自动同步-CSDN博客
炒股久了就会发现:指数定方向,情绪定强度。上证指数涨了不代表能赚钱,要是涨停股寥寥无几、下跌股扎堆,大概率是“指数虚涨”;反之,哪怕指数微涨,但涨停股多、赚钱效应足,行情就有参与价值。而能最直观反映市场情绪的,就是同花顺的核心行情数据——包含大盘评分、涨跌停数量、涨跌分布等关键信息。
上一篇我们搞定了大盘指数的实时监控,这一次(系列第十一篇),我们升级量化数据体系:打造同花顺核心行情抓取神器!核心是工作日交易时段按精准Cron表达式(每10分钟1次)抓取同花顺行情接口,解析出大盘评分、涨跌数量、涨跌停分布等核心情绪数据,实时缓存到Redis,让你毫秒级获取市场情绪全貌,避开“指数陷阱”,精准把握交易节奏!
一、为啥非要抓同花顺行情?3个核心价值,秒杀普通行情软件
可能有朋友疑惑:“我看盘软件里也能看到涨跌停数据,为啥还要专门抓取?” 其实,同花顺的核心行情数据,藏着普通软件没有的3个“交易密码”,这正是我们动手的核心原因:
- 市场情绪量化评分:直接给出大盘赚钱效应评分(0-10分),新手也能秒懂“当前市场能不能参与”,不用再自己瞎琢磨;
- 全维度涨跌分布:从“跌停~-8%”到“8%~涨停”分10个区间统计股票数量,一眼看清资金是集中在强势股还是弱势股;
- 赚钱效应追踪:包含昨日涨停今日收益数据,能判断短线热点的持续性,这是做短线交易的核心参考。
- 无干扰无延迟:跳过同花顺软件的广告和冗余功能,直接抓取核心数据,还能自定义缓存和后续预警,完美对接我们的量化系统。
更关键的是,我们用Java实现的这套抓取系统,能按自己的节奏精准调度,数据实时存入Redis,后续不管是做策略触发还是数据回溯,都能直接复用,真正把数据主动权握在自己手里。
二、核心需求与技术选型:精准、稳定、可复用
2.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秒触发,避开行情数据更新高峰;
- 数据完整:抓取同花顺大盘评分、涨跌数量、涨跌停数量、10档涨跌分布、昨日涨停今日收益等核心数据;
- 稳定抓取:自定义请求头(含Cookie、Hexin-V等关键参数),模拟浏览器访问,避免被同花顺接口拦截;
- 实时缓存:解析后的数据封装为ThsHqInfoDto对象,存入Redis,key固定为“stock_public:now:thsHq”,方便后续快速查询;
- 异常兼容:抓取失败或数据为空时直接返回,不影响系统其他功能运行。
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); }
}
四、核心实现(二):同花顺数据抓取(关键:请求头构造)
同花顺接口有一定的反爬机制,核心是要构造正确的请求头(含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;
}
}
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;
}
提示: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;
}
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;
}
}
重点说明:这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
}
]
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())
);
}
}
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 数据表:
-
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格式后再解析; - 请求头配置:必须添加
Host和Cookie字段(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线数据同步的基础需求,但在生产环境中需补充以下优化点,确保系统稳定性、效率与数据完整性:
- 批量同步优化:扩展接口支持多只股票批量同步,采用线程池异步处理单只股票的同步流程,提升整体同步效率;
- 数据去重机制:同步前先查询数据库中已存在的历史数据,按“股票代码+交易日期”作为唯一键,避免重复插入(可通过Mybatis-Plus的
insertOrUpdateBatch方法实现); - 分页获取适配:当单只股票历史数据超过800条时(
lmt=800),需实现分页获取逻辑(通过调整beg参数为上一批次的最后一个日期); - 多K线类型支持:封装
klt参数为枚举类(如KlineType.DAY=101、KlineType.WEEK=102),支持日K、周K、月K等多类型同步; - 监控与告警:集成监控工具(如Prometheus + Grafana)监控同步成功率、同步数据量等指标,同步失败时通过钉钉/邮件告警;
- 接口切换预案:准备备用历史数据接口(如雪球、同花顺接口),当东方财富接口不可用时自动切换,提升系统可用性。
五、系列文章预告
本文完成了历史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如下:
-
CREATE TABLE `stock_selected` (
-
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键自增',
-
`stock_code` varchar(8) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL COMMENT '股票的编号',
-
`stock_name` varchar(100) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '股票的名称',
-
`user_id` int DEFAULT NULL COMMENT '用户的id',
-
`create_time` timestamp NULL DEFAULT NULL COMMENT '创建时间',
-
`code_notes` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '笔记',
-
`status` int NOT NULL COMMENT '状态 0为禁用 1为启用',
-
`flag` int DEFAULT NULL COMMENT '1是正常0为删除',
-
PRIMARY KEY (`id`) USING BTREE
-
) 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数据库交互,核心代码:
-
@Data
-
@EqualsAndHashCode(callSuper = false)
-
@TableName("stock_selected")
-
public class StockSelectedDo implements Serializable {
-
-
private static final long serialVersionUID = 1L;
-
-
/** 主键自增 */
-
@TableId(value = "id", type = IdType.AUTO)
-
private Integer id;
-
-
/** 股票的编号 */
-
@TableField("stock_code")
-
private String stockCode;
-
-
/** 股票的名称 */
-
@TableField("stock_name")
-
private String stockName;
-
-
/** 用户的id */
-
@TableField("user_id")
-
private Integer userId;
-
-
/** 创建时间 */
-
@TableField("create_time")
-
private Date createTime;
-
-
/** 笔记(对应表中code_notes字段) */
-
@TableField("code_notes")
-
private String notes;
-
-
/** 是否启用,禁用 1为启用,0为禁用 */
-
@TableField("status")
-
private Integer status;
-
-
/** 是否删除 1为正常 0为删除(表中flag字段定义) */
-
@TableField("flag")
-
private Integer flag;
-
}
关键说明:通过@TableName关联数据库表,@TableId指定主键策略(自增),@TableField关联表字段(notes对应表中code_notes,需显式指定字段名)。
1.3.2 请求对象:StockSelectedRo
接收前端请求参数(如查询条件、添加信息、编辑笔记内容),核心字段包括userId(当前登录用户ID)、stockCode(股票代码)、notes(笔记)等,按需扩展查询关键词字段。
1.3.3 响应对象:StockSelectedVo
返回前端展示数据,在StockSelectedDo基础上屏蔽数据库字段(如flag逻辑删除标识),仅暴露用户需查看的字段(id、stockCode、stockName、notes、status、createTime)。
二、核心流程设计:四大功能的完整链路
主要是对单表的进行维护, 故只列出关键的逻辑。
自选股票维护的四大核心功能,均遵循“请求参数校验 → 业务逻辑处理 → 数据库交互 → 结果返回”的链路,不同功能的核心差异在于业务逻辑校验环节(如添加需校验股票存在性和重复性,删除需清理缓存)。整体流程概括如下:
前端请求 → 控制器接收参数 → 拦截器获取当前用户ID → 业务层校验参数合法性 → 数据层交互(查/增/删/改) → 响应结果返回
三、核心实现:分步拆解四大功能代码
3.1 控制器层:接口定义与参数预处理
首先定义自选股票维护的核心接口,采用RESTful风格,通过POST请求接收参数,同时在控制器层完成基础参数校验(如股票代码非空)和当前用户ID注入(通过getUserId()方法从上下文获取,需集成用户登录认证功能)。
核心代码实现:
-
@RestController
-
@RequestMapping("/stock/selected")
-
public class StockSelectedController {
-
-
@Resource
-
private StockSelectedBusiness stockSelectedBusiness;
-
-
/**
-
* 1. 查询用户自选股票(支持分页、关键词模糊搜索)
-
* @param stockSelectedRo 包含查询条件(股票代码、名称、状态等)的请求对象
-
* @return 分页后的自选股票列表
-
*/
-
@Operation(summary = "查询用户自选股票")
-
@PostMapping("/list")
-
public OutputResult<PageResponse<StockSelectedVo>> list(@RequestBody StockSelectedRo stockSelectedRo) {
-
// 注入当前登录用户ID,确保数据隔离
-
stockSelectedRo.setUserId(getUserId());
-
return stockSelectedBusiness.listSelected(stockSelectedRo);
-
}
-
-
/**
-
* 2. 添加股票到自选表
-
* @param stockSelectedRo 包含股票代码的请求对象
-
* @return 添加结果(成功/失败提示)
-
*/
-
@Operation(summary = "添加到自选表")
-
@PostMapping("/add")
-
public OutputResult add(@RequestBody StockSelectedRo stockSelectedRo){
-
// 基础校验:股票代码不能为空
-
if (!StrUtil.isNotBlank(stockSelectedRo.getStockCode())){
-
return OutputResult.buildAlert(ResultCode.STOCK_CODE_IS_EMPTY);
-
}
-
// 注入当前登录用户ID
-
stockSelectedRo.setUserId(getUserId());
-
return stockSelectedBusiness.add(stockSelectedRo);
-
}
-
-
/**
-
* 3. 根据股票代码删除自选股票(股票页面专用)
-
* @param codeRo 包含股票代码的请求对象
-
* @return 删除结果(成功/失败提示)
-
*/
-
@Operation(summary = "根据股票code进行移除,股票页面使用")
-
@PostMapping("/deleteByCode")
-
public OutputResult deleteByCode(@RequestBody CodeRo codeRo){
-
// 基础校验:股票代码不能为空
-
if (!StrUtil.isNotBlank(codeRo.getCode())){
-
return OutputResult.buildAlert(ResultCode.STOCK_CODE_IS_EMPTY);
-
}
-
// 转换为自选股票请求对象,注入当前登录用户ID
-
StockSelectedRo stockSelectedRo = new StockSelectedRo();
-
stockSelectedRo.setUserId(getUserId());
-
stockSelectedRo.setStockCode(codeRo.getCode());
-
return stockSelectedBusiness.deleteByCode(stockSelectedRo);
-
}
-
-
/**
-
* 4. 编辑自选股票笔记
-
* @param stockSelectedRo 包含记录ID/股票代码、笔记内容的请求对象
-
* @return 编辑结果(成功/失败提示)
-
*/
-
@Operation(summary = "根据自选记录,编辑笔记")
-
@PostMapping("/editNotes")
-
public OutputResult editNotes(@RequestBody StockSelectedRo stockSelectedRo){
-
// 注入当前登录用户ID
-
stockSelectedRo.setUserId(getUserId());
-
// 基础校验:必须指定记录ID或股票代码(定位要编辑的记录),笔记内容不能为空
-
if (stockSelectedRo.getId() == null && !StrUtil.isNotBlank(stockSelectedRo.getStockCode())) {
-
return OutputResult.buildAlert(ResultCode.ID_IS_EMPTY);
-
}
-
if (!StrUtil.isNotBlank(stockSelectedRo.getNotes())) {
-
return OutputResult.buildAlert(ResultCode.STOCK_SELECTED_NOTES_EMPTY);
-
}
-
return stockSelectedBusiness.editNotes(stockSelectedRo);
-
}
-
-
/**
-
* 从上下文获取当前登录用户ID
-
*/
-
private Integer getUserId() {
-
Integer userId = ThreadLocalUtils.getUserId();
-
if (userId != null) {
-
return userId;
-
}
-
return 1;
-
}
-
}
关键注意点:
- 用户ID注入:通过
getUserId()方法从用户上下文获取当前登录用户ID,确保不同用户的自选数据隔离,这是自选功能的核心设计点; - 基础参数校验:在控制器层完成简单的非空校验(如股票代码、笔记内容),避免无效请求进入业务层,提升系统效率;
- 接口文档:通过
@Operation注解生成接口文档(集成Knife4j/Swagger),便于前后端联调。
3.2 业务层:核心逻辑实现(重点)
业务层是自选功能的核心,负责完成参数合法性校验、关联数据查询(如校验股票是否存在)、业务规则执行(如避免重复添加)、缓存处理等核心逻辑。下面按四大功能分别拆解:
3.2.1 功能一:查询用户自选股票
核心逻辑:根据当前用户ID,结合查询条件(如股票代码/名称关键词、状态)进行分页查询,支持模糊搜索(通过股票代码或名称匹配关键词)。
核心代码实现:
根据 股票编码 或者 股票名称 进行查询
3.2.2 功能二:添加股票到自选表
核心逻辑:添加前需完成三重校验——① 股票是否存在(通过股票代码/名称/完整代码查询基础股票表);② 该股票是否已添加到当前用户的自选表(避免重复);③ 自选股票数量是否超限(如最多添加100只)。校验通过后,插入自选表并默认设置状态为“启用”(status=1)。
核心代码实现:
-
@Service
-
public class StockSelectedBusinessImpl implements StockSelectedBusiness {
-
-
@Resource
-
private StockSelectedService stockSelectedService;
-
@Resource
-
private StockService stockService;
-
-
@Override
-
public OutputResult add(StockSelectedRo stockSelectedRo) {
-
try {
-
// 1. 校验1:查询股票是否存在(支持通过股票代码、完整代码、名称查询)
-
StockDto stockDto = stockService.getByCodeOrNameOrFullCode(stockSelectedRo.getStockCode());
-
if (null == stockDto) {
-
// 股票不存在,返回错误提示
-
return OutputResult.buildAlert(ResultCode.STOCK_CODE_NO_EXIST);
-
}
-
-
// 2. 统一股票代码格式(避免因完整代码/简称导致的重复)
-
stockSelectedRo.setStockCode(stockDto.getCode());
-
-
// 3. 校验2:该股票是否已添加到当前用户的自选表(避免重复添加)
-
OutputResult addValidateResult = stockSelectedService.validateAdd(stockSelectedRo, 100);
-
if (!addValidateResult.getSuccess()) {
-
return addValidateResult;
-
}
-
-
// 4. 校验通过,添加到自选表(默认状态:启用status=1,正常flag=1,创建时间为当前时间)
-
stockSelectedService.add(stockSelectedRo, stockDto.getName());
-
-
// 5. 返回添加成功提示(包含股票代码和名称)
-
return OutputResult.buildSucc(stockDto.getCode() + "(名称:" + stockDto.getName() + ")添加到自选成功");
-
} catch (Exception e) {
-
log.error("用户 {} 添加自选股票 {} 失败", stockSelectedRo.getUserId(), stockSelectedRo.getStockCode(), e);
-
return OutputResult.buildFail("添加自选股票失败:" + e.getMessage());
-
}
-
}
-
}
关键补充说明:
- 股票存在性校验:通过
stockService.getByCodeOrNameOrFullCode()方法查询基础股票表,支持股票代码、完整代码(如0.001318)、股票名称三种查询方式,提升用户操作便捷性; - 重复添加校验:
stockSelectedService.validateAdd()方法内部通过“user_id + stock_code”组合查询,判断该股票是否已在当前用户的自选表中(且状态为正常); - 数量限制校验:
validateAdd()方法第二个参数“100”为自选股票数量上限,避免用户添加过多标的导致查询效率下降。
3.2.3 功能三:根据股票代码删除自选股票
核心逻辑:删除前需查询股票是否存在,然后执行逻辑删除(更新flag字段为0,而非物理删除),同时清理该股票的价格缓存(如Redis中存储的实时价格),避免缓存数据不一致。
核心代码实现:
-
@Service
-
public class StockSelectedBusinessImpl implements StockSelectedBusiness {
-
-
@Resource
-
private StockSelectedService stockSelectedService;
-
@Resource
-
private StockService stockService;
-
@Resource
-
private RedisUtil redisUtil;
-
-
@Override
-
public OutputResult deleteByCode(StockSelectedRo stockSelectedRo) {
-
try {
-
// 1. 校验股票是否存在,获取标准股票代码
-
StockDto stockDto = stockService.getByCodeOrNameOrFullCode(stockSelectedRo.getStockCode());
-
if (null == stockDto) {
-
return OutputResult.buildAlert(ResultCode.STOCK_CODE_NO_EXIST);
-
}
-
String realCode = stockDto.getCode();
-
stockSelectedRo.setStockCode(realCode);
-
-
// 2. 执行逻辑删除(更新flag=0,而非物理删除)
-
OutputResult deleteResult = stockSelectedService.deleteByCode(stockSelectedRo);
-
if (!deleteResult.getSuccess()){
-
return deleteResult;
-
}
-
-
// 3. 清理缓存:删除该股票的实时价格缓存(避免缓存残留)
-
redisUtil.delete(Collections.singleton(Const.STOCK_PRICE + realCode));
-
-
return OutputResult.buildSucc("删除自选股票 " + realCode + " 成功");
-
} catch (Exception e) {
-
log.error("用户 {} 删除自选股票 {} 失败", stockSelectedRo.getUserId(), stockSelectedRo.getStockCode(), e);
-
return OutputResult.buildFail("删除自选股票失败:" + e.getMessage());
-
}
-
}
-
}
关键注意点:
- 逻辑删除:通过更新
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注解配置正确。
四、核心优化点与生产环境适配
上述实现已满足自选股票维护的基础需求,但在生产环境中需补充以下优化点,确保系统稳定性、效率与用户体验:
- 缓存优化:查询用户自选股票列表时,添加Redis缓存(缓存key为
USER_SELECTED_STOCK + userId),避免频繁查询数据库;添加/删除/编辑操作后,同步更新缓存(删除旧缓存或更新缓存内容); - 权限控制:集成Spring Security/Shiro等认证框架,确保
getUserId()方法能安全获取当前登录用户ID,避免未登录用户访问自选接口; - 参数校验增强:使用JSR-380注解(如
@NotBlank、@NotNull)替代手动非空校验,结合全局异常处理器统一处理校验失败信息; - 异步处理:对于批量添加/删除自选股票的场景(扩展功能),采用线程池异步处理,提升用户操作体验;
- 日志与监控:记录自选股票操作日志(用户ID、操作类型、股票代码、操作时间),便于问题排查和用户行为分析;集成监控工具(如Prometheus + Grafana)监控接口调用量和失败率。
五、系列文章预告
本文完成了量化系统“用户交互层”的核心模块——自选股票维护,实现了用户对核心标的的便捷管理。下一篇文章将聚焦 “获取股票的实时价格”,为后续策略回测功能铺路。
最后,留一个思考问题:在自选股票列表查询场景中,如何设计缓存策略(如缓存过期时间、更新机制),才能在保证查询效率的同时,避免缓存与数据库数据不一致?欢迎在评论区交流~

浙公网安备 33010602011771号