在前六篇内容中,我们已经完成了股票数据爬取、自选股管理、实时价格缓存等核心功能,从“能获取数据”逐步升级到“能便捷查看数据”。但对量化交易来说,“看数据”只是基础,“分析数据”才是核心——比如判断一只股票近1周、1个月的涨跌趋势,或是通过一段时间的价格、成交量走势挖掘规律,这些都需要一套完善的统计分析能力支撑。
本文作为Java量化系列的第七篇,将聚焦“股票统计分析系统”的落地实现,核心覆盖两大高频需求:周级别的涨跌统计(快速掌握中短期波动)和折线图数据统计(多维度可视化分析)。我们会结合提供的完整业务代码,从接口设计、逻辑拆解、数据转换、异常处理四个层面,完整还原系统搭建过程,帮你快速掌握股票统计分析的核心技术要点。
一、核心需求与整体设计思路
1.1 统计分析的核心价值
对普通投资者或量化交易者而言,统计分析的核心价值在于“降维复杂数据,提炼关键信息”:
- 周级统计:无需手动对比每日价格,一键获取近1周、2周、3周、1个月的涨跌金额和幅度,快速判断中短期趋势;
- 折线图统计:支持自定义时间范围,生成开盘价、收盘价、成交量等多维度数据,适配ECharts等前端组件实现可视化,直观发现价格波动与成交量的关联规律;
- 灵活适配:支持按需求筛选统计维度(如仅看收盘价和成交量),避免冗余数据,提升分析效率。
1.2 整体技术架构
统计分析系统基于此前搭建的量化基础框架,核心依赖“历史数据服务”(提供股票历史行情数据)和“工具类服务”(处理日期、数值计算),整体架构流程如下:
前端请求(携带股票代码、统计条件)→ 接口参数校验 → 业务层处理(周级计算/折线图数据转换)→ 历史数据服务查询数据 → 数据格式化封装 → 返回标准化结果(适配前端展示)
核心依赖组件:
- 接口层:Spring Boot + Swagger(通过@Operation注解描述接口,方便调试);
- 业务层:自定义StatBusiness,封装周级统计和折线图统计的核心逻辑;
- 数据层:StockHistoryService,提供历史行情数据的查询能力;
- 工具类:DateUtil(日期处理)、BigDecimalUtil(高精度数值计算)、CollUtil(集合处理)。
二、核心实现(一):周级涨跌统计功能
周级统计的核心逻辑是:根据传入的股票代码,查询“最近一个交易日”和“近1周/2周/3周/1个月的最后一个交易日”的收盘价,计算两者的涨跌金额和幅度,最终封装成标准化结果返回。
2.1 接口设计与参数校验
首先设计周级统计接口,采用POST请求,接收包含股票代码的请求对象StockStatRo,核心做两层参数校验:股票代码非空校验、股票是否存在校验,避免无效请求。
/**
* 获取股票周级别统计信息
* @param stockStatRo 包含查询条件的请求对象(核心字段:code-股票代码)
* @return 包含股票周级别统计信息的输出结果
*/
@Operation(summary = "股票周级别统计信息")
@PostMapping("/getWeekStat")
public OutputResult<StockWeekStatVo> getWeekStat(@RequestBody StockStatRo stockStatRo) {
// 第一层校验:股票代码不能为空
if (StrUtil.isBlank(stockStatRo.getCode())) {
return OutputResult.buildAlert(ResultCode.STOCK_CODE_IS_EMPTY);
}
// 调用业务层处理,返回结果
return statBusiness.getWeekStat(stockStatRo);
}
2.2 核心业务逻辑实现
业务层statBusiness的getWeekStat方法是核心,完整实现“股票有效性校验→统计时间点计算→历史数据查询→涨跌金额/幅度计算→结果封装”的全流程。
@Override
public OutputResult<StockWeekStatVo> getWeekStat(StockStatRo stockStatRo) {
// 1. 校验股票是否存在(通过股票代码查询基础信息)
StockDto stockDto = stockService.getByCode(stockStatRo.getCode());
if (stockDto == null) {
return OutputResult.buildAlert(ResultCode.STOCK_CODE_NO_EXIST);
}
// 2. 计算统计时间点:最近1个交易日、近1周、2周、3周、1个月的最后一个交易日
int offset = -1; // 偏移量:-1表示“前一天”
// 结束时间:当前日期的前一天(确保是交易日,后续通过dateHelper处理)
DateTime endDate = DateUtil.date().offsetNew(DateField.DAY_OF_YEAR, offset);
List<DateTime> searchDateList = new ArrayList<>();
// 统计时间点:近1周(-7天)、近2周(-14天)、近3周(-21天)、近1个月(-1个月)
searchDateList.add(DateUtil.date().offsetNew(DateField.DAY_OF_YEAR, offset - 7));
searchDateList.add(DateUtil.date().offsetNew(DateField.DAY_OF_YEAR, offset - 14));
searchDateList.add(DateUtil.date().offsetNew(DateField.DAY_OF_YEAR, offset - 21));
searchDateList.add(DateUtil.date().offsetNew(DateField.MONTH, -1));
// 3. 初始化返回结果对象
StockWeekStatVo stockWeekStatVo = new StockWeekStatVo();
List<StockWeekStatInfoVo> weekStatInfoVoList = new ArrayList<>();
// 4. 查询“最近一个交易日”的历史数据(作为基准值)
StockHistoryVo lastVo = stockHistoryService.getLastHistoryVoByCodeAndDate(
stockStatRo.getCode(), dateHelper.getBeforeLastWorking(endDate)
);
// 5. 遍历统计时间点,计算每个时间段的涨跌信息
int weekIndex = 1; // 用于标记统计类型(1-近1周,2-近2周,3-近3周,4-近1个月)
for (DateTime dateTime : searchDateList) {
// 查询当前统计时间点的最后一个交易日的历史数据
StockHistoryVo tempVo = stockHistoryService.getLastHistoryVoByCodeAndDate(
stockStatRo.getCode(), dateHelper.getBeforeLastWorking(dateTime)
);
// 6. 计算涨跌金额和幅度
StockWeekStatInfoVo stockWeekStatInfoVo = new StockWeekStatInfoVo();
stockWeekStatInfoVo.setType(weekIndex);
// 类型名称:通过WeekStatType枚举获取(如1对应“近1周”)
stockWeekStatInfoVo.setTypeName(WeekStatType.getExchangeType(weekIndex).getDesc());
// 涨跌金额 = 最近交易日收盘价 - 统计时间点收盘价(保留4位小数)
BigDecimal rangePrice = BigDecimalUtil.sub4(lastVo.getClosingPrice(), tempVo.getClosingPrice());
stockWeekStatInfoVo.setRangePrice(BigDecimalUtil.toShowString4(rangePrice));
// 涨跌幅度 = 涨跌金额 / 统计时间点收盘价 * 100(转成百分比格式)
String rangeProportion = BigDecimalUtil.div4Mul100(rangePrice, tempVo.getClosingPrice());
stockWeekStatInfoVo.setRangeProportion(rangeProportion);
weekStatInfoVoList.add(stockWeekStatInfoVo);
weekIndex++;
}
// 7. 封装结果并返回
stockWeekStatVo.setWeekStatInfoList(weekStatInfoVoList);
return OutputResult.buildSucc(stockWeekStatVo);
}
2.3 关键设计亮点与注意事项
- 时间处理精准性:通过dateHelper.getBeforeLastWorking()方法获取“指定日期之前的最后一个交易日”,自动过滤周末和节假日,确保统计的是有效交易数据;
- 高精度数值计算:使用BigDecimalUtil工具类的sub4(减法保留4位小数)、div4Mul100(除法后乘100转百分比)方法,避免浮点数精度丢失(股票价格计算核心要求);
- 枚举类复用:通过WeekStatType枚举统一管理统计类型名称(如“近1周”“近1个月”),避免硬编码,后续修改统计维度时更便捷;
- 异常友好处理:股票代码不存在时,返回明确的错误提示(ResultCode.STOCK_CODE_NO_EXIST),便于前端展示和问题排查。
三、核心实现(二):折线图数据统计功能
折线图统计的核心需求是“生成适配前端可视化组件的数据格式”,核心逻辑是:根据传入的股票代码、时间范围、统计维度,查询历史数据并转换为“图例+X轴日期+系列数据”的标准化格式,支持前端直接渲染折线图。
3.1 接口设计与参数说明
折线图统计接口同样采用POST请求,接收的StockStatRo对象包含三个核心参数:code(股票代码)、startDate(开始日期)、endDate(结束日期)、charStockTypeList(统计维度列表,可选)。
/**
* 获取股票图形统计信息(适配折线图可视化)
* @param stockStatRo 包含查询条件的请求对象
* 核心字段:code-股票代码、startDate-开始日期、endDate-结束日期、charStockTypeList-统计维度列表
* @return 包含图形统计信息的输出对象
*/
@Operation(summary = "股票图形统计信息")
@PostMapping("/getCharStat")
public OutputResult<LineVo> getCharStat(@RequestBody StockStatRo stockStatRo) {
// 校验股票代码非空
if (!StrUtil.isNotBlank(stockStatRo.getCode())) {
return OutputResult.buildAlert(ResultCode.STOCK_CODE_IS_EMPTY);
}
return statBusiness.getCharStat(stockStatRo);
}
3.2 核心业务逻辑实现
业务层statBusiness的getCharStat方法分为三个核心步骤:确定统计维度(图例)、计算X轴日期列表、查询历史数据并转换为系列数据。
@Override
public OutputResult<LineVo> getCharStat(StockStatRo stockStatRo) {
// 步骤1:确定统计维度(图例列表)
List<String> legendList = new ArrayList<>();
// StockCharMoneyType枚举:定义所有支持的统计维度(开盘价、收盘价、成交量等)
StockCharMoneyType[] allTypes = StockCharMoneyType.values();
// 若未指定统计维度,默认返回所有维度;否则按指定维度筛选
if (CollUtil.isEmpty(stockStatRo.getCharStockTypeList())) {
for (StockCharMoneyType type : allTypes) {
legendList.add(type.getDesc()); // 如“开盘价”“收盘价”
}
} else {
for (String typeStr : stockStatRo.getCharStockTypeList()) {
// 支持传入维度编码(如1对应开盘价)或直接传入维度名称
if (NumberUtil.isInteger(typeStr)) {
StockCharMoneyType type = StockCharMoneyType.getTypeByCode(Integer.parseInt(typeStr));
if (type != null) {
legendList.add(type.getDesc());
}
} else {
legendList.add(typeStr);
}
}
}
// 步骤2:计算X轴日期列表(仅包含交易日)
List<String> xaxisData = new ArrayList<>();
String startDate = stockStatRo.getStartDate();
String endDate = stockStatRo.getEndDate();
// 解析开始日期和结束日期
DateTime start = DateUtil.parse(startDate, Const.SIMPLE_DATE_FORMAT);
DateTime end = DateUtil.parse(endDate, Const.SIMPLE_DATE_FORMAT);
// 获取时间范围内的所有交易日(自动过滤周末、节假日)
List<Date> workDayList = dateHelper.betweenWorkDay(start, end);
// 格式化日期为字符串(如“2025-12-01”),适配前端展示
for (Date date : workDayList) {
xaxisData.add(DateUtil.format(date, Const.SIMPLE_DATE_FORMAT));
}
// 步骤3:初始化折线图结果对象
LineVo lineVo = new LineVo();
lineVo.setLegend(legendList); // 图例
lineVo.setXaxisData(xaxisData); // X轴日期
// 步骤4:查询指定时间范围的股票历史数据
List<StockHistoryVo> historyVoList = stockHistoryService.listStockHistoryVoByCodeAndDateRange(
stockStatRo.getCode(), start, end
);
// 若无历史数据,直接返回空系列数据(避免前端报错)
if (CollUtil.isEmpty(historyVoList)) {
return OutputResult.buildSucc(lineVo);
}
// 步骤5:将历史数据转换为折线图系列数据(适配前端格式)
List<LineSeriesVo> seriesList = historyConvertLine(historyVoList, xaxisData.size());
// 按统计维度筛选系列数据(只保留用户指定的维度)
seriesList = seriesList.stream()
.filter(series -> legendList.contains(series.getName()))
.collect(Collectors.toList());
// 步骤6:封装系列数据并返回
lineVo.setSeries(seriesList);
return OutputResult.buildSucc(lineVo);
}
3.3 核心工具方法:历史数据转折线图数据
historyConvertLine方法是折线图数据转换的核心,负责将StockHistoryVo列表(历史行情数据)转换为LineSeriesVo列表(前端可视化需要的系列数据),同时处理“数据长度不足时补0”的问题,确保X轴日期与系列数据一一对应。
/**
* 将历史数据转换成折线图所需的系列数据
* @param stockHistoryVoList 股票历史行情数据列表
* @param size X轴日期的长度(确保系列数据长度与之匹配)
* @return 折线图系列数据列表
*/
private List<LineSeriesVo> historyConvertLine(List<StockHistoryVo> stockHistoryVoList, int size) {
List<LineSeriesVo> result = new ArrayList<>();
// 1. 初始化各维度的系列数据对象(对应开盘价、收盘价等)
LineSeriesVo openingPrice = new LineSeriesVo();
openingPrice.setName(StockCharMoneyType.OPENING_PRICE.getDesc());
LineSeriesVo closingPrice = new LineSeriesVo();
closingPrice.setName(StockCharMoneyType.CLOSING_PRICE.getDesc());
LineSeriesVo highestPrice = new LineSeriesVo();
highestPrice.setName(StockCharMoneyType.HIGHEST_PRICE.getDesc());
LineSeriesVo lowestPrice = new LineSeriesVo();
lowestPrice.setName(StockCharMoneyType.LOWEST_PRICE.getDesc());
LineSeriesVo amplitudeProportion = new LineSeriesVo();
amplitudeProportion.setName(StockCharMoneyType.AMPLITUDE_PROPORTION.getDesc());
LineSeriesVo tradingVolume = new LineSeriesVo();
tradingVolume.setName(StockCharMoneyType.TRADING_VOLUME.getDesc());
LineSeriesVo changingProportion = new LineSeriesVo();
changingProportion.setName(StockCharMoneyType.CHANGING_PROPORTION.getDesc());
LineSeriesVo than = new LineSeriesVo();
than.setName(StockCharMoneyType.THAN.getDesc());
LineSeriesVo avgPrice = new LineSeriesVo();
avgPrice.setName(StockCharMoneyType.AVG_PRICE.getDesc());
// 2. 遍历历史数据,填充各维度的数值
for (StockHistoryVo historyVo : stockHistoryVoList) {
openingPrice.getData().add(BigDecimalUtil.toDouble(historyVo.getOpeningPrice()));
closingPrice.getData().add(BigDecimalUtil.toDouble(historyVo.getClosingPrice()));
highestPrice.getData().add(BigDecimalUtil.toDouble(historyVo.getHighestPrice()));
lowestPrice.getData().add(BigDecimalUtil.toDouble(historyVo.getLowestPrice()));
amplitudeProportion.getData().add(BigDecimalUtil.toDouble(historyVo.getAmplitudeProportion()));
tradingVolume.getData().add(BigDecimalUtil.toDouble(historyVo.getTradingVolume()));
changingProportion.getData().add(BigDecimalUtil.toDouble(historyVo.getChangingProportion()));
than.getData().add(BigDecimalUtil.toDouble(historyVo.getThan()));
avgPrice.getData().add(BigDecimalUtil.toDouble(historyVo.getAvgPrice()));
}
// 3. 数据长度补0:若历史数据长度小于X轴日期长度,前面补0(确保一一对应,前端渲染不错位)
if (openingPrice.getData().size() < size) {
int lackSize = size - openingPrice.getData().size();
List<Double> zeroList = new ArrayList<>(lackSize);
for (int i = 0; i < lackSize; i++) {
zeroList.add(BigDecimal.ZERO.doubleValue());
}
// 为所有维度的系列数据补0
openingPrice.getData().addAll(0, zeroList);
closingPrice.getData().addAll(0, zeroList);
highestPrice.getData().addAll(0, zeroList);
lowestPrice.getData().addAll(0, zeroList);
amplitudeProportion.getData().addAll(0, zeroList);
tradingVolume.getData().addAll(0, zeroList);
changingProportion.getData().addAll(0, zeroList);
than.getData().addAll(0, zeroList);
avgPrice.getData().addAll(0, zeroList);
}
// 4. 将所有维度的系列数据添加到结果列表
result.add(openingPrice);
result.add(closingPrice);
result.add(highestPrice);
result.add(lowestPrice);
result.add(amplitudeProportion);
result.add(tradingVolume);
result.add(changingProportion);
result.add(than);
result.add(avgPrice);
return result;
}
3.4 关键设计亮点与注意事项
- 维度灵活筛选:支持用户指定统计维度(如仅看收盘价和成交量),通过stream过滤实现按需返回,减少数据传输量;
- 数据对齐处理:历史数据长度不足时(如时间范围内包含非交易日),自动在前面补0,确保X轴日期与系列数据一一对应,避免前端折线图渲染错位;
- 标准化输出格式:返回的LineVo对象包含“图例、X轴日期、系列数据”,完全适配ECharts等前端可视化组件的要求,前端可直接使用,无需额外处理;
- 兼容多种传入格式:统计维度支持传入编码(如1)或名称(如“开盘价”),提升接口的易用性。
四、核心优化点与生产环境适配
上述实现已满足基础的统计分析需求,若要在生产环境使用,需补充以下优化点:
- 缓存优化:对周级统计结果和折线图数据添加Redis缓存(缓存key包含股票代码、时间范围、统计维度),缓存有效期设为1小时(行情数据短期变化不大),减少数据库查询压力;
- 限流保护:通过Spring Cloud Gateway或自定义拦截器,对统计接口进行限流(如单IP每分钟最多10次请求),避免恶意请求导致系统过载;
- 数据权限控制:若支持多用户使用,需在接口中添加用户ID参数,仅允许用户查询自己关注的自选股统计数据,确保数据隔离;
- 日志增强:在关键节点(如数据查询失败、维度转换异常)添加详细日志,记录股票代码、时间范围等信息,便于问题排查;
- 前端适配示例:提供简单的ECharts渲染示例,帮助前端快速对接接口(如折线图渲染收盘价和成交量的双Y轴图表)。
五、系列文章预告
本文完成了量化系统的“统计分析层”搭建,实现了周级涨跌统计和折线图数据统计两大核心功能,从此我们不仅能获取股票数据,还能通过统计分析挖掘数据规律。下一篇文章将聚焦“ 推送每日自选股票数据到个人邮箱“
最后,留一个思考问题:在处理大量股票的统计请求时,如何通过异步处理或批量处理提升系统的并发能力?欢迎在评论区交流你的想法~