Java量化系列(六):摸鱼神器!构建专属股票实时价格系统

59 阅读13分钟

作为量化交易者,既想实时掌握自选股票的价格动态,又不想在工作时间频繁打开行情软件“暴露行踪”?前面我们已经实现了自选股票维护和股票详情获取功能,这一篇就基于这两大基础,打造一个“摸鱼专属”的股票实时价格系统——核心逻辑是:将感兴趣的股票添加到自选列表,通过定时任务在交易时间自动抓取实时价格并更新到Redis,后续只需查看Redis中的数据,就能悄无声息掌握行情,完美适配摸鱼场景!

本文作为Java量化系列的第六篇,将聚焦“实时价格系统的核心构建”,基于Spring定时任务、Redis缓存技术,完整实现“定时调度→批量抓取→缓存存储”的全链路逻辑。提供的代码已涵盖核心业务逻辑,我们会逐模块拆解设计思路、关键细节和优化点,即使是新手也能跟着实现一套属于自己的实时价格系统。

一、核心需求与场景设计

1.1 摸鱼场景核心诉求

不同于专业的行情软件,摸鱼场景下的实时价格系统更注重“低存在感”“高可靠性”“轻量化”,核心需求如下:

  • 定时自动更新:交易时间内自动
  • 抓取自选股票价格,无需手动触发,避免频繁操作暴露;
  • 精准时间调度:仅在股票交易时间(9:30-11:30、13:00-15:00)执行更新,非交易时间不浪费资源;且14:57后需规避尾盘波动,延迟执行避免无效请求;
  • 批量高效抓取:支持多只自选股票批量更新,每批控制数量避免触发数据源反爬,同时异步执行提升效率;
  • Redis缓存存储:价格数据存储到Redis,查看时直接访问Redis(如通过Redis Desktop Manager工具),无需打开行情软件;缓存有效期合理设置,兼顾实时性与性能;
  • 异常兼容:抓取失败时不影响整体系统运行,股票名称缺失时自动补充,保证缓存数据完整性。

1.2 核心流程设计

整个实时价格系统的核心流程可概括为“三步走”,形成闭环:

自选股票录入 → 定时任务触发(交易时间内) → 批量抓取实时价格(调用第五篇的多数据源接口) → Redis缓存更新/存储 → 摸鱼时查看Redis数据

其中,“定时任务触发”“批量抓取价格”“Redis缓存存储”是三大核心模块,也是本文的重点拆解内容。

二、核心设计:定时任务调度策略

定时任务是系统的“心脏”,需精准控制执行时间:仅在工作日的交易时段执行,非交易时间、节假日不执行;14:57后需延迟执行,避免尾盘高频波动导致的无效抓取。这里采用Spring的定时任务注解(@Scheduled)结合Cron表达式实现调度,同时通过时间工具类控制执行边界。

2.1 Cron表达式设计

根据需求,定时任务需在 交易日期内 执行(即每15s执行一次,避开开盘前的9:00-9:30和午休时间),对应的Cron表达式为:

1/15 * 9,10,11,13,14 ? * 1-5

Cron表达式拆解(从左到右):

  • 1/15:秒位,每隔15s执行一次
  • *:分位,每1分钟执行一次;
  • 9,10,11,13,14:时位,仅在9-11点、13-14点执行(避开12点午休);
  • ?:日位,不指定具体日期;
  • *:月位,所有月份都执行;
  • 1-5:星期位,仅周一到周五(工作日)执行。

2.2 定时任务核心代码实现

定时任务核心逻辑:先判断当前是否为交易时间(非交易时间直接返回);若为14:57后(尾盘时段),则休眠4分钟避开波动;最后异步执行自选股票价格更新操作,避免阻塞任务调度。

@Component
public class StockPriceSchedule {

    @Resource
    private StockSelectedService stockSelectedService;
    @Resource
    private DateHelper dateHelper;
    // 线程池,用于异步执行价格更新,避免阻塞定时任务
    @Resource
    private Executor executor;

    /**
     * 定时更新自选股票实时价格(摸鱼专属)
     */
    @Scheduled(cron = "1/15 * 9,10,11,13,14 ? * 1-5")
    @Override
    public void execute(String param) {
        // 1. 时间校验:非交易时间直接返回(避免节假日、午休、开盘前/收盘后执行)
        // dateHelper.isWorkingTime():自定义工具类方法,判断当前是否为股票交易时间(9:30-11:30、13:00-15:00)
        if (dateHelper.isWorkingTime(new Date())) {
            return;
        }

        // 2. 尾盘处理:14:57之后休眠4分钟,避开尾盘波动,确保查询时间在15:00之后
        if (MyDateUtil.isWillEndStockPriceTime()) {
            // 休眠240秒(4分钟),使后续价格查询在收盘后执行,避免频繁波动导致的无效数据
            MyDateUtil.sleep(1000 * 240);
        }

        // 3. 异步执行价格更新:提交到线程池,避免阻塞定时任务调度(核心:不影响下一次任务执行)
        executor.execute(
                () -> {
                    // 更新所有自选股票的价格(param传null表示更新全部)
                    stockSelectedService.updateSelectedCodePrice(null);
                }
        );
    }
}

2.3 关键时间工具类说明

上述代码中依赖两个核心时间工具类方法,确保任务执行的时间准确性:

  • dateHelper.isWorkingTime():判断当前时间是否为股票交易时间(9:30-11:30、13:00-15:00),非交易时间(如9:00-9:29、12:00-12:59)直接返回,不执行价格更新;
  • MyDateUtil.isWillEndStockPriceTime():判断当前时间是否在14:57之后(尾盘时段),若是则休眠4分钟,避免在尾盘高频波动时频繁抓取无效数据;
  • MyDateUtil.sleep():自定义休眠方法,封装Thread.sleep(),简化异常处理。

三、核心实现:自选股票价格批量更新

定时任务触发后,核心逻辑是“批量获取自选股票代码→分批抓取价格→异步执行更新”,避免单只股票抓取失败影响整体,同时控制每批数量规避数据源反爬。这部分逻辑主要在StockSelectedService中实现,分为三个核心方法:updateSelectedCodePrice(入口方法)、updateSelectedCodePriceByList(分批处理)、batchGenerateRealPriceData(批量抓取)。

3.1 入口方法:updateSelectedCodePrice

核心作用:接收股票代码参数(可为null),若指定代码则更新单只股票价格,若为null则批量更新所有用户的自选股票价格;同时对股票代码进行分批处理(每5只为一批),避免一次性请求过多触发反爬。

@Service
public class StockSelectedServiceImpl implements StockSelectedService {

    @Resource
    private Executor executor;

    @Override
    public void updateSelectedCodePrice(String code) {
        // 初始化用于执行价格更新的股票代码列表
        List<String> executeCodeList;

        // 1. 单只股票更新:若指定了股票代码,直接更新该股票
        if (StrUtil.isNotBlank(code)) {
            updateSelectedCodePriceByList(Collections.singletonList(code));
        } else {
            // 2. 批量更新:获取所有用户的自选股票代码(findCodeByUserId(-1):-1表示查询所有用户的自选股票)
            List<String> selectCodeList = findCodeByUserId(-1);
            // 校验股票代码列表是否为空,为空则直接返回
            if (CollUtil.isEmpty(selectCodeList)) {
                return;
            }

            // 3. 代码列表处理:将查询到的代码添加到待执行列表
            List<String> codeList = new ArrayList<>();
            Optional.ofNullable(selectCodeList).ifPresent(codeList::addAll);

            // 4. 分批处理:每5只为一批,避免一次性请求过多触发数据源反爬
            int batchSize = 5; // 每批数量,可根据数据源反爬策略调整
            for (int i = 0; i < codeList.size(); i += batchSize) {
                // 计算当前批次的最后一个索引(避免越界)
                int maxIndex = Math.min(i + batchSize, codeList.size());
                // 提取当前批次的股票代码列表
                executeCodeList = codeList.subList(i, maxIndex);
                // 异步执行当前批次的价格更新
                List<String> finalExecuteCodeList = executeCodeList;
                updateSelectedCodePriceByList(finalExecuteCodeList);
            }
        }
    }
}

3.2 分批处理方法:updateSelectedCodePriceByList

核心作用:接收一批股票代码,将价格更新任务提交到线程池异步执行,同时休眠50毫秒避免频繁请求数据源,进一步降低反爬风险。

@Override
public void updateSelectedCodePriceByList(List<String> codeList) {
    // 提交异步任务:批量更新一批股票的实时价格
    List<String> finalExecuteCodeList1 = codeList;
    executor.submit(
            () -> {
                try {
                    // 调用股票抓取服务,批量生成实时价格数据
                    stockCrawlerService.batchGenerateRealPriceData(finalExecuteCodeList1);
                    // 休眠50毫秒:控制请求频率,避免触发数据源反爬机制
                    TimeUnit.MILLISECONDS.sleep(50);
                } catch (Exception e) {
                    // 异常处理:抓取失败时忽略,避免单批失败影响整体(生产环境可添加日志记录)
                    log.error("批量更新股票价格失败,代码列表:{}", finalExecuteCodeList1, e);
                }  
            }
    );
}

3.3 批量抓取方法:batchGenerateRealPriceData

核心作用:接收一批股票代码,转换为全代码(如001318→sz001318),调用第五篇实现的多数据源接口(getStockDetail)抓取实时价格,再通过StockCacheService将价格数据缓存到Redis。

StockNowPriceDto 存储缓存对象:

@Data
@Builder
public class StockNowPriceDto implements Serializable {
    private String code;
    private String name;
    private BigDecimal price;
    private String percent;
    private String timestamp;

    @Tolerate
    public StockNowPriceDto() {}

    public String getTimestamp() {
        return DateUtil.now();
    }
}
@Service
public class StockCrawlerServiceImpl implements StockCrawlerService {

    @Resource
    private StockDetailService stockDetailService;
    @Resource
    private StockCacheService stockCacheService;
    @Resource
    private StockAssember stockAssember; // DTO转换器:StockShowInfoDto → StockNowPriceDto

    // 批量生成股票实时价格数据,并缓存到Redis
    private void batchGenerateRealPriceData(List<String> codeList) {
        // 校验代码列表是否为空,为空则直接返回
        if (CollUtil.isEmpty(codeList)) {
            return;
        }

        // 1. 代码格式转换:将纯代码(如001318)转换为全代码(如sz001318、sh600000)
        // StockUtil.getFullCode():自定义工具类方法,根据股票代码前缀判断交易所(如00开头→深交所sz,60开头→上交所sh)
        List<String> fullCodeList = codeList.stream().map(
                StockUtil::getFullCode
        ).collect(Collectors.toList());

        // 2. 遍历代码列表,抓取每只股票的实时价格并缓存
        for (String code : codeList) {
            // 调用第五篇实现的多数据源接口,获取股票实时详情(自动切换财联社/腾讯数据源)
            StockShowInfoDto stockDto = getStockDetail(code);
            // 转换DTO:将StockShowInfoDto(完整详情)转为StockNowPriceDto(仅价格相关字段,轻量化)
            StockNowPriceDto nowPriceDto = stockAssember.showInfo2NowPrice(stockDto);
            // 缓存到Redis:调用StockCacheService的方法
            stockCacheService.setNowCachePrice(code, nowPriceDto);
        }
    }
}

3.4 关键说明

  • 代码格式转换:StockUtil.getFullCode()是核心工具方法,根据股票代码规则转换为全代码(如00开头→sz,60开头→sh,30开头→sz,68开头→sh),确保能正确调用数据源接口;
  • DTO转换:通过StockAssember.showInfo2NowPrice()将完整的StockShowInfoDto转换为轻量化的StockNowPriceDto(仅保留代码、名称、当前价、涨跌幅度等核心字段),减少Redis存储占用;
  • 异步执行:从定时任务到分批更新,全程采用异步执行,避免单只股票抓取缓慢或失败影响整体任务进度。

四、核心实现:Redis缓存存储与优化

Redis是摸鱼场景的“核心载体”,所有实时价格数据都存储在这里,查看时只需打开Redis客户端(如Redis Desktop Manager)即可,无需打开行情软件。这部分核心逻辑是“缓存key设计→数据完整性处理→缓存有效期设置”,确保数据可靠且轻量化。

4.1 缓存核心代码实现

@Service
public class StockCacheServiceImpl implements StockCacheService {

    @Resource
    private RedisUtil redisUtil;
    @Resource
    private StockDomainService stockDomainService;

    @Override
    public void setNowCachePrice(String code, StockNowPriceDto price) {
        // 1. 空值校验:若价格DTO为空,直接返回(避免缓存空数据)
        if (price == null) {
            return;
        }

        // 2. 股票名称补充:若名称为空,从股票基础表查询补充(避免缓存中名称缺失)
        if (!StrUtil.isNotBlank(price.getName())){
            StockQueryParam stockQueryParam = new StockQueryParam();
            stockQueryParam.setCode(code);
            // 从股票基础表(stock表)查询股票信息
            StockDo stockDo = stockDomainService.getByCondition(stockQueryParam);
            // 若查询到则补充名称,否则设为“未知股票名”
            String stockName = Optional.ofNullable(stockDo).map(StockDo::getName).orElse("未知股票名");
            price.setName(stockName);
        }

        // 3. 缓存存储:设计合理的key,设置有效期为3天
        String cacheKey = Const.STOCK_PRICE + code; // 缓存key格式:STOCK_PRICE:001318
        // 有效期3天:兼顾实时性(交易时间每15s更新一次)和离线查看需求
        redisUtil.set(cacheKey, price, 3, TimeUnit.DAYS);
    }
}

4.2 关键设计亮点

  • 缓存Key设计:采用“固定前缀+股票代码”的格式(如STOCK_PRICE:001318),清晰易识别,后续查看或删除缓存时更便捷;
  • 数据完整性处理:若抓取到的价格数据中股票名称为空(如数据源返回异常),自动从股票基础表(stock表)查询补充,避免缓存中出现“未知股票”的无效数据;
  • 有效期设置:缓存有效期设为3天,既保证交易时间内每10分钟更新一次的实时性,又支持非交易时间(如周末)离线查看历史数据,适配摸鱼场景的灵活需求;
  • 轻量化存储:存储的是StockNowPriceDto(仅核心价格字段),而非完整的StockShowInfoDto,减少Redis内存占用,提升查询速度。

4.3 摸鱼查看方式

缓存存储后,查看方式超简单,全程无需打开行情软件:

  1. 安装Redis客户端工具(如Redis Desktop Manager、Another Redis DeskTop Manager);
  2. 连接部署Redis的服务器(本地或云服务器);
  3. 在Redis中找到对应的缓存Key(如STOCK_PRICE:001318),点击即可查看存储的StockNowPriceDto数据,包含股票名称、当前价、涨跌幅度等核心信息。

小贴士:可将Redis客户端工具设置为“老板键”(如Ctrl+~),工作时一键隐藏,摸鱼查看更安全!

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

上述实现已满足摸鱼场景的基础需求,但在生产环境中使用或进一步优化时,需补充以下要点:

  1. 线程池优化:自定义线程池参数(核心线程数、最大线程数、队列容量),避免默认线程池导致的资源耗尽问题;例如核心线程数设为5,最大线程数设为10,队列容量设为100,适配批量更新场景;
  2. 日志增强:在关键节点(如定时任务触发、批量抓取失败、缓存更新成功)添加详细日志,便于问题排查;例如记录每批更新的股票代码、抓取耗时、缓存Key等信息;
  3. 失败重试机制:对抓取失败的股票代码,添加重试机制(如最多重试3次,每次间隔1秒),避免因网络波动导致的单次失败;可通过Spring Retry实现重试逻辑;
  4. Redis集群适配:若部署在生产环境,建议使用Redis集群(主从复制+哨兵模式),避免单点故障导致缓存不可用;同时开启Redis持久化(RDB+AOF),防止数据丢失;
  5. 代码解耦:将StockUtil、MyDateUtil等工具类抽取为独立模块,提升代码复用性;将Cron表达式、每批处理数量、缓存有效期等配置项放入配置文件(application.yml),便于后续动态调整;
  6. 权限控制:若多用户使用,需在缓存Key中添加用户ID前缀(如STOCK_PRICE:USER1001:001318),确保不同用户的自选股票数据隔离,避免数据混乱。

六、系列文章预告

本文完成了量化系统“摸鱼专属模块”——股票实时价格系统的构建,实现了定时调度、批量抓取、Redis缓存的全链路逻辑,从此可以悄无声息掌握自选股票动态。下一篇文章将聚焦“股票查询和统计“,进一步提升摸鱼体验!

最后,留一个思考问题:在多用户场景下,如何优化批量更新逻辑(如按用户分组更新、避免重复抓取同一股票),才能兼顾效率与数据隔离?欢迎在评论区交流~