摸鱼酱在此分享一套自己从0到0.1开发的辅助炒股系统

·  阅读 2646

摸鱼酱的文章声明:内容保证原创,纯技术干货分享交流,不打广告不吹牛逼。

前言:掘友们好,快一年没更新掘金文章了,不知还否有人记得我摸鱼酱。短短一年之间,摸鱼酱从加入阿里努力工作到接触炒股再到离职阿里全职炒股,从谈笑风生到怀疑人生,一切都是那么迅速那么突然,仿佛梦一场,一场梦~。

image.png

闲话不多说,下面进入正题。对于这套辅助炒股系统的行文逻辑,我将不会过多关注于系统技术栈以及技术细节上的探讨,而是会把重心更多地放在系统本身要解决的需求以及面对的问题上,正如下脑图:

image.png

接下来的行文,我都会围绕这副脑图展开,如果您对下面的内容或者对源码实现感兴趣,那么我希望您能在这幅图上多停留多一些时间,因为这幅图能对您的理解产生极大的帮助。更期望的是,如果您能从自己的编程或者炒股经验出发,对这个系统提出一些指正或者建议,那就更好啦,阿里嘎多~。

好地,遵循上述脑图中的逻辑,接下来我会分成以下几个部分展开探讨本文。

  • 交易数据的拉取和建模(tushare)
  • 程序从哪些方面帮助交易(service-core)
  • 快速大量的数据服务(service-data)
  • 贴心的通知服务(service-notify)

好的,理清了行文思路之后,下面我们进入第一部分,交易数据的拉取和建模(tushare)。

一:交易数据的拉取和建模(tushare)

如果阁下曾经想过要自己做量化或者像我一些写些辅助炒股的工具,那么必然您遇到的首个问题肯定就是交易数据从何来。我相信,很多人可能都会和我最初的想法一样,就是写爬虫脚本爬取财经网站(如新浪、东方财富、天天基金网等等)的数据存储到本地。但是这会碰到很多问题,比如:

  • 寻找数据麻烦:有些数据很难甚至找不到爬取的渠道,比如复权因子、同花顺概念这些
  • 直面防爬机制:比如限制访问次数或者直接封IP
  • 解析建模繁杂:比如奇怪的接口响应格式,dom中数据的解析建模就更麻烦了
  • 数据可靠性:比如数据是否准确,什么时间更新时间

这些问题在我编写这个系统的前身(一个选ETF的脚本)的过程中都切身体会过,真是烦的雅痞。在此之后,我调研了几家支持量化的社区和平台(各种quant),发现主要有四种形式:

  • 第一种是图形化操作完成策略
  • 第二种是是提供代码编写运行的平台(大部分都是用的python语言)
  • 第三种是提供数据api接口的SDK(基本都是只支持python)
  • 最后一种是直接http提供数据(比如tushare)

记忆深刻一点的量化平台是果仁网,它属于第一种,特点是无需编写代码,勾勾选选就可以完成一个策略,这很方便那些喜欢技术分析炒股的人去回测。ps:很多都要付费,我为了试试功能,他喵的充了300会员(能不能把钱还我 ~)

很明显对于我们前端狗自己写程序来说,能用的也只有最后一种。类似tushare这样提供交易数据的平台应该也还有一些,但是能有tushare平台这样好的文档以及认真维护的的态度的平台,我确实还没见过第二家。

ps:tushare中一些高级数据的访问以及高频访问数据也是需要充钱的,个人是前后充值了1500块钱左右吧。

好的,下面我就分享一下在这个辅助炒股系统(shares-help)中,我是如何利用tushare平台来拉取数据和数据建模的。

1.程序拉取数据问题

对于数据的拉取,借助于tushare平台,我们只需要在tushare官方文档中找到对应的数据接口说明,然后使用发送http请求即可,对于具体实现,下面我们以拉取A股股票日线数据作为示例:

下面代码看起来有些长,但涉及到的逻辑也就两个方面:1是封装发送请求获取数据的函数(getDaily)、2是测试函数逻辑(test)。

import axios from 'axios';
import { tushare_token } from '../../tushare.config';
// 接口:daily,可以通过数据工具调试和查看数据。
// 数据说明:交易日每天15点~16点之间。本接口是未复权行情,停牌期间不提供数据。
// 描述:获取股票行情数据,或通过通用行情接口获取数据,包含了前后复权数据。
const api_name = 'daily';

export type DailyParams = {
  ts_code?: string; // 	股票代码(支持多个股票同时提取,逗号分隔)
  trade_date?: string; // 交易日期(YYYYMMDD)
  start_date?: string; // 开始日期(YYYYMMDD)
  end_date?: string; // 	结束日期(YYYYMMDD)
};

export type DailyResult = {
  ts_code?: string; // 	股票代码
  trade_date?: string; // 	交易日期
  open?: number; // 开盘价
  high?: number; // 最高价
  low?: number; // 最低价
  close?: number; // 	收盘价
  pre_close?: number; // 昨收价
  change?: number; // 	涨跌额
  pct_chg?: number; // 涨跌幅 (未复权,如果是复权请用 通用行情接口 )
  vol?: number; // 成交量 (手)
  amount?: number; // 成交额 (千元)
};

export type HsConstField =
  | 'ts_code'
  | 'trade_date'
  | 'open	'
  | 'high'
  | 'low'
  | 'close'
  | 'pre_close'
  | 'change'
  | 'pct_chg'
  | 'vol'
  | 'amount';

export const getDaily = async (
  token: string,
  params?: DailyParams,
  fields?: HsConstField[] | string,
): Promise<DailyResult[]> | null => {
  let result = null;
  if (Array.isArray(fields)) {
    fields = fields.join(',');
  }
  const reqBody = {
    api_name,
    token,
    params: params ? params : {},
    fields: fields,
  };
  await axios
    .post('http://api.waditu.com', reqBody)
    .then((res) => {
      const { code, msg, data } = res.data;
      if (code !== 0) {
        console.log('reqError', msg, reqBody);
        result = null;
      }
      const { fields, items } = data;
      const mapResult = items.map((itemArr) => {
        const obj = {};
        for (let i = 0; i < itemArr.length; i++) {
          obj[fields[i]] = itemArr[i];
        }
        return obj;
      });
      result = mapResult;
    })
    .catch((error) => {
      // console.log('reqBody', reqBody);
      // console.log(error);
      result = null;
    });
  return result;
};

async function test() {
  const data = await getDaily(tushare_token, {
    ts_code: '600132.SH',
    trade_date: '20211027',
  });
  console.log(data);
}

// test();

复制代码

上述代码就是shares-help中拉取交易数据的范本,代码中除了暴露拉取数据的请求方法及其参数和返回数据的ts类型供外部使用之外,还提供一个test方法用于测试函数逻辑(单独测试时使用ts-node运行指定文件即可)。在shares-help中,每个接口请求文件都只维护一个接口的请求测试逻辑并和tushare中的接口文档一对一。

如果后续tushare有接口更新或增加的话,阁下可以使用tushare/help-script下的生成脚本来快速生成这些文件。

shares-help中拉取交易数据的分享就暂时到这,下面讨论一下基于tushare下的数据建模。

2.交易数据建模问题

可能大家不是很理解我所说的交易数据建模是啥意思。我举个例子,每只股票每天都会产生很多数据,如价格、涨跌幅、成交量、复权因子、市值、换手率等等很多因子。那么我们应该如何合理的把这些因子进行定义、归纳、组合,让程序运行更有逻辑和效率呢?在往前我使用爬虫爬取交易数据的阶段,就因为按照自己的炒股和编程经验来建模,结果建模不合理,进而导致了几次数据库调整,策略代码修改,非常浪费时间。

众所周知,底层数据结构的设计对于程序来说非常关键。一旦底层数据结构变化,很容易带来数据库的重构以及程序的大幅改动。

这个头疼问题的解决,是我在引入tushare之后最大的收获,例如上面所说的每只股票每天所产生数据的因子,在tushare中就合理分布到了daily、dailyBasic、adj_factor三张表中。

正如上daily接口的接口请求文件中暴露的dailyResult对象,它奠定了整个shares-help中对于daily行情数据的传输结构。也就是说,shares-help程序中有关交易数据的dto定义在了每个接口请求文件中

shares-help对于交易数据拉取和建模问题的分享暂时到这里,下面我们进入核心部分,shares-help能够从哪些方面帮助交易?

二:程序从哪些方面帮助交易(service-core)

从我的认知来说,理想的程序化交易链路应该是这样的:自动分析市场 => 自动选出股票 => 自动买卖股票 => 自动回测收益 => 通知交易结果,俗称全自动交易,就像一台无情的印钞机~

可惜的是,当前的shares-help并不能实现日内的交易数据分析和自动下单功能,这源于它当前无法获得分钟级数据以及还未引入券商开放的交易接口。毕竟咱们现在还只是0.1嘛,也许之后的版本会实现哦~。

鉴于A股T+1的交易机制以及概念炒作风格,分钟级数据和自动下单功能好像也没有那么重要~。

基于日内数据无法获取也无法自动下单,那么根据上面的交易链路,我们思考整理一下shares-help当前应该要具备哪些能力:

  • 收盘后,可以对当日与以往市场进行一些数据统计和分析,以了解市场概况并思考接下来交易日的操作策略
  • 收盘后,拿到当日交易数据执行选股策略,为下一日荐股
  • 模拟买卖,使用日线数据计算收益
  • 收盘回测当日,或者回测历史交易日以甄别选股策略和操作策略是否合理

这些程序逻辑都在微服务core-service中划分成了四个模块,分别和以上四个需求有如下对应:

  • shares-analysis:市场分析
  • shares-select:标的选择
  • shares-operate:模拟买卖
  • shares-backTest:策略回测

用uml类图来描述系统中对这些模块的实现以及依赖如下图:

image.png

下面我会以举例子的形式继续分享如何在shares-help中做好以上四件事情,需要提前说明的是,下面我举的例子和策略都会比较浅陋。这受限于本人的炒股经验,我并不知道分析市场的哪些东西是有效的,更没有啥好的选股以及操作策略。所以阁下尽量关注shares-help程序结构本身而不是炒股策略哈~

人最迷茫的时候不是不知道怎么做,而是不知道做什么 ~

1.市场分析(shares-analysis)

市场分析这个模块设计来就是随意发挥的,如果你有想法,可以在这里分析大盘、分析外资、分析个股,分析啥都可以,比如shares-analysis中举的例子就来源于以下设想:

假设我们的操作思路是在尾盘买入一支股票,第二天挂单在涨幅为1的位置,那么只要这只个股在一天的震荡当中,只要产生过超过涨幅为1的价格就会成交进而盈利,振幅之间没有超过涨幅为1的情况就会亏损。为了提高盈利概率,我们选股时,就要选择那些第二天容易振幅上1个点的个股,使用程序回测选中的股票中实际振幅中涨幅超过1%的个股占选中股票的比例,比例越大意味着盈利概率越大(盈利概率大并不意味着盈利,因为赔率小)。

关乎这个例子的逻辑我们不予辩证,前面我也提到,这些例子设想都比较粗陋,实现上也比较随意,所以下面我们结合uml类图来介绍一下实现的基本思路(对细节感兴趣的可以看shares-help源码):

image.png

  • step1:在shares-analysis/stock中新建一个AmplitudeAnalysis类
  • step2:在getStockPool中编写拉取股票池的逻辑
  • step3:在poolFilter中编写选股逻辑,筛选得到选中的股票
  • step4:在stockFilter编写振幅涨幅中超过1%的股票过滤器
  • step5:在程序入口start中按序调用这些方法,并用stockFilter筛选后的数量/poolFilter筛选后的数量,得到我们要求的概率。

对于上述uml类图中的成员属性我简单介绍一下,成员属性tushare用于快捷取出所需的数据,notifyClient用于快捷发送邮件等通知,sharesAnalusisUtil用于调用一些工具方法。

成员属性tushare能够快捷拉取数据得益于service-data模块提供的服务,notifyClient能够快捷发送通知得益于service-notify模块提供的服务,这两个模块后面会分别展开分享。

好的,举完例子那么简单的标的分析分享就到这了,对细节实现感兴趣的朋友可以看看shares-filter源码。如果你也有过一些天马行空的设想希望回测,那么也可以基于shares-filter编写代码来快速验证哦~。

下面我们就进入标的选择模块~

2.标的选择(shares-select)

标的选择这个模块中都是一些选择标的策略,比如选择股票、基金、可转债、数字货币等等都可以,shares-select中暂时就编写了一个A股龙头板块追板的策略例子,它来自于以下思考:

假设我们是一个A股短线玩家,就我的炒股认知来说,A股短线主要是跟随以游资为主导的短线资金来炒作概念,是一场刀尖上舔血,高风险高收益的击鼓传花游戏。这场游戏通常是由先知先觉的大资金来发起,等到我们这些后知后觉的小资金散户发现的时候,常常也就是这场游戏临近尾声的时候。很显然,这是一场低胜率低赔率的赌博,它像一块放在扑鼠夹下的奶酪,专门捕杀我们这些自作聪明或者心存侥幸的短线老鼠。

对于这场不公平的游戏,管住自己的手,不做偷吃的老鼠必然是一个非常好的选择。但是他喵的短线奶酪香啊,而且没有时间成本,我真好想吃,哈哈,所谓一入股市深似海,从此亲友是路人也不无道理,沉迷股市的大多都无可救药吧。

在离职阿里炒股的这快半年间,我一直都在思考和尝试做一只聪明的老鼠。有没有成功呢,哈哈,阁下看到这篇找工作的文章,就是对我是一名loser的宣判。我,并没有找到答案。当然,这半年间也不是一无所获,我学习了一些投资书籍,尝试实践了各种类型的炒股策略,比如技术分析炒股、基本面炒股、消息炒股、情绪炒股等等。

对了,在这里我提一嘴量化炒股,就我对AI的认知而言,量化炒股本质上属于技术分析炒股,与macd、kdj这些传统方法有所不同的是,AI量化考虑的因子更多,建立的模型更加全面罢了。所有的技术分析炒股方案都有一个致命的问题,那就是后视镜看路。只有当路是直的情况下,技术分析才不会翻车。总的来说,就我理解,量化作为新时代的技术分析方案,在中长线或者说炒波段的情况,它确实相比传统的技术分析方案有所优势。在短线的场景,结合大资金优势,通过资金和情绪两个维度来做量化也是可行的。

好的,扯远了,回到这个短线选股例子。它的思路是这样的:

  • 寻找板块效应:找到同花顺前一日的龙头板块(涨幅最大且超过5个点)
  • 寻找板块内强势股:找到前一日该板块下所有涨停的个股(ps:之所以不只找龙头,那是因为龙头大概率买不进)

同样,对于实现我会结合以下uml类图来展开分享实现思路:

image.png

  • step1:在shares-select/ths中新建一个LimitThsSelect类
  • step2:在LimitThsSelect类的getSharesPool方法中实现拉取所有同花顺板块的逻辑
  • step3:在LimitThsSelect类的thsFilter方法中实现寻找前一日涨幅第一板块的逻辑
  • step4:实现LimitThsSelect类的start方法供controller直接使用,实现start_core方法供后面的股票选择逻辑使用。
  • step5:在shares-select/stock中新建一个LimitStockSelect类,继承BaseStockSelect抽象类
  • step6:在LimitStockSelect类的getStockPool方法中实现拉取需要的股票池,如只要主板或者只要500亿以下的股票
  • step7:在LimitStockSelect类的stockFilter1中实现寻找涨停股票的逻辑

经过以上步骤,我们就可以实现自己的选股想法,如果觉得这个选股方法对自己现实中的交易有所帮助,那么还可以在shares-select/schedule.select.ts中实现每天收盘后定时跑策略给自己第二天荐股,代码实现非常简单,如下即可:

@Injectable()
export default class ScheduleSelect {
  constructor(
    public readonly tushareData: TushareData,
    public readonly limitStockSelect: LimitStockSelect,
    @Inject('SERVICE_NOTIFY') public readonly notifyClient: ClientProxy,
  ) {}

  @Cron('0 30 20 * * 1-5') // 每周一到周五晚八点半推送当日选股
  async dailyNotify() {
    const tradeCal = await this.tushareData.getTradeCal();
    const nowDate = getNowDate();
    const afterDay = addDay(nowDate);
    const isTradeDay = tradeCal[nowDate].is_open === 1;
    if (isTradeDay) {
      const adviseCodes = await this.limitStockSelect.start_core(afterDay);
      await this.notifyClient.emit('stock_select', adviseCodes);
    }
  }
}

复制代码

好的,对于在shares-help中如何完成选股策略的分享就到此结束,下面我们进入第三个模块,在shares-help中模拟买卖操作。

3.买卖操作(shares-operate)

买卖这个模块中都是一些现实中买卖策略的抽象,在这里你可以编写一些买卖策略比如做短线时,计算某个选股策略下第一天早晚盘买入至第二天早晚盘卖出的收益有多少,或者做波段时在某个选股条件下设置止盈位止损位看看平均持仓要多长时间。也就是说,这个模块一是可以直接用来做收益或者持仓长短的计算器,二是可以为回测模块服务。

shares-operate当前有一个短线追板操作策略的例子,可用于计算上面所述的A股龙头板块追板选股策略的收益。它的具体买卖逻辑是这样的:

  • 买入:均仓分布,以当日开盘价买入
  • 卖出:买入日断板开盘卖出,不断板在盘中以当日最低价与最高价的折中价卖出

同样,对于实现我会结合以下uml类图来展开分享实现思路:

image.png

  • step1:在shares-operate/stock下新建一个LimitStockOperate类继承BaseStockOperate
  • step2:在LimitStockOperate类的getBuyInfo中实现买入逻辑
  • step3:在LimitStockOperate类的getBuyInfo中实现卖出逻辑
  • step4:在LimitStockOperate类的getHoldInfo中实现持有逻辑

经过以上步骤,我们就可以实现自己计算短线追板操作选股策略的收益。至于其它买卖策略的开发在这里就不多做介绍了,下面我就快速进入策略回测模块的分享把。

4.策略回测(shares-backTest)

策略回测模块可以用来回测当日收益或者历史收益,在执行程序选股和程序买卖策略时,回测当日收益可以判断自己的实际操作是否和程序预期有偏差,如果有偏差,考虑是否自己的情绪没有把控到位,是不是太贪了或者太怂了。回测历史收益则通常用来验证自己的选股和操作策略是否经得住历史的考验。

shares-help中当前有一个短线追板选股及配套的操作策略例子,它们的选股和买卖策略在此就不重复介绍了,编写好了选股和操作策略的代码,那么回测就很简单了,对于单日回测,无非就是以下过程:

  • 调用选股策略筛选出股票
  • 拿到选好的股票塞进操作策略里,计算操作结果
  • 拿到操作结果,统计收益

完成了单日回测逻辑,多日回测逻辑也就是简单的循环和统计了。当然啦,你也可以做的复杂点,反正例子就是这么简单哈~

这里值得一提的是选股、操作与回测之间依赖关系的设计和实现,先看看uml图:

image.png

在上一个版本的设计当中,上述三个类合并成了一个策略类,一个策略就包含了选股、操作和回测的所有逻辑,那样的缺点是逻辑都耦合在了一起,扩展性差代码也非常的臃肿。所以在当前版本的设计,经过交易链路的重新梳理之后,我采用了适配器模式的思想来重构,选股策略对象和操作策略对象作为回测对象的成员属性

好的,对于service-core的探讨我就先到这了,如果阁下对源码感兴趣,那么可以直接访问github.com/iamjwe/shar… 。在shares-help中,service-core的代码可以写的非常简洁,这其中很大的原因是因为拉取数据和通知的逻辑都被分别封装到了service-data和service-notify两个微服务当中,下面我就先进入service-data服务的分享,展开说说在service-data中遇到的问题以及解决方案。

如果看到这了,那就帮忙点个赞吧,阿里嘎多~

三:快速大量的数据服务(service-data)

shares-help做的很好的一点是把数据处理逻辑和策略逻辑以微服务形式解耦了,这样做的最大好处是能够使得两者的关注度分离,大大降低程序的复杂性。上面已经分享过了如何在shares-help中编写策略,下面在分享shares-help的数据服务模块之前,我们先简单思考一下对于一个辅助炒股的系统而言,一个能够让使用者舒心、省心的数据服务需要做到哪些?我认为应该包含以下四点:

  • 准确:请求条件下返回的交易数据必须是准确的,其次数据是否为空、请求是否失败都要合理处理。
  • 大量:在历史回测场景下需要一下提供非常多的数据,高频拉取第三方平台的数据必然会受到限制,这个问题必须解决。
  • 高效:不断优化速度,高频访问的数据应该加以缓存,频繁使用的数据在程序启动时就应该预缓存。
  • 即食:一些需要加工的数据需要在这个环节加工好,比如股票价格的复权。其次对外响应的数据应该封装成一个支持合理高效访问的数据结构。

在这四个目标当中,准确性和即食性在程序中加入响应逻辑能比较好的解决,在此就不展开探讨。下面我核心分享一下在shares-help中是如何解决大量拉取数据和快速响应数据这两个问题的,下图是service-data模块中解决这两个问题的代码文件组织结构:

image.png

1.大量拉取数据问题

tushare平台高频拉取数据会受到限制,大部分接口都是限制了一分钟访问接口的次数,基本都是500800次,A股股票数量都有4000多,很显然这个限制问题绕不开。这个问题大家很自然的可以想到自建数据库,不管是用本地电脑或者买一个云端服务器都可以,我个人的话主要是自己电脑基本没用存储空间了加上放在云端方便几个小伙伴共享就选择了购买云端服务器的方案。我建议追求响应速度的话还是建一个本地数据库快一些,十块钱一个月的学生机搭的数据库真的好慢(捋羊毛很抱歉,我穷)。

而对于数据库数据的同步和完整性校验逻辑,这部分逻辑分布在了各个service-data模块下的各个.sync.ts文件中。对于具体逻辑,下面我就以股票日线数据的同步和完整性校验为例(stock.sync.ts):

export const CACHE_KEY_STOCK_DATA_SYNC = {
  STOCK_BASIC_READY: 'stock_basic_ready',
  ADJ_FACTOR_READY: 'adj_factor_ready',
  DAILY_BASIC_READY: 'daily_basic_ready',
  DAILY_READY: 'daily_ready',
  WEEKLY_READY: 'weekly_ready',
  MONTHLY_READY: 'monthly_ready',
};

// 同步Tuhsare数据到数据库中
@Injectable()
export class StockDataSync implements OnModuleInit {
  useMyDb: boolean;
  token: string;
  tradeCalBegin: string;
  constructor(
    @InjectRepository(StockBasic)
    private stockBasicRepository: Repository<StockBasic>,
    @InjectRepository(AdjFactor)
    private adjFactorRepository: Repository<AdjFactor>,
    @InjectRepository(DailyBasic)
    private dailyBasicRepository: Repository<DailyBasic>,
    @InjectRepository(Daily)
    private dailyRepository: Repository<Daily>,
    @InjectRepository(Weekly)
    private weeklyRepository: Repository<Weekly>,
    @InjectRepository(Monthly)
    private monthlyRepository: Repository<Monthly>,
    @Inject(CACHE_MANAGER) public readonly cacheManager: Cache,
    @Inject(ConfigService) private readonly configServices: ConfigService,
    private readonly baseData: BaseData,
    private readonly baseDataUtil: BaseDataUtil,
  ) {
    const { useMyDb, token, tradeCalBegin } =
      this.configServices.get('tushare');
    this.token = token;
    this.tradeCalBegin = tradeCalBegin; // 获取量化日历和量化数据的开始日期
    this.useMyDb = useMyDb;
  }

  @Cron('0 0 17 * * 1-5') // 所有需检查数据项的最后更新时间
  async onModuleInit() {
    if (this.useMyDb) {
      console.log('开始进行股票数据完整性校验');
      //   启动时/每天多少点检查数据是否实时是否可靠,如果不可靠控制台输出警告
      let stock_basic_ready;
      let adj_factor_ready;
      let daily_basic_ready;
      let daily_ready;
      let weekly_ready;
      let monthly_ready;
      const pArr = [
        this.checkStockBasicReady(),
        this.checkAdjFactorReady(),
        this.checkDailyBasicReady(),
        this.checkDailyReady(),
        this.checkWeeklyReady(),
        this.checkMonthlyReady(),
      ];
      await Promise.all(pArr).then((values) => {
        stock_basic_ready = values[0];
        adj_factor_ready = values[1];
        daily_basic_ready = values[2];
        daily_ready = values[3];
        weekly_ready = values[4];
        monthly_ready = values[5];
        console.log('结束股票数据完整性校验');
      });
    }
  }

  // ... 其它交易数据的完整性校验和同步函数

  async fillDailyDataByTradesDates(tradeDates: string[]): Promise<void> {
    const dailys = [];
    const tradeCal = await this.baseData.getTradeCal();
    const pArr = [];
    for (let j = 0; j < tradeDates.length; j++) {
      const trade_date = tradeDates[j];
      if (tradeCal[trade_date].is_open === 1) {
        pArr.push(
          getDaily(this.token, {
            trade_date,
          }).catch((e) => {
            console.log(`daily数据抓取错误,请检查日期${trade_date}`);
          }),
        );
      }
    }
    await Promise.all(pArr).then((values) => {
      values.forEach((val) => {
        if (!val) {
          return;
        }
        dailys.push(...val);
      });
    });
    for (let i = 0; i < dailys.length; i += 5000) {
      const tempArr = dailys.slice(i, i + 5000);
      await this.dailyRepository.save(tempArr);
    }
  }

  async fillDailyData(startDate: string, endDate: string): Promise<void> {
    const datesSpliceByYear = spliceByYear(startDate, endDate);
    const years = Object.keys(datesSpliceByYear);
    for (let i = 0; i < years.length; i++) {
      const tradeDates = datesSpliceByYear[years[i]];
      await this.fillDailyDataByTradesDates(tradeDates);
    }
  }

  async checkDailyReady(): Promise<boolean> {
    let daily_ready = false;
    const needStartDate = (
      await this.baseDataUtil.utilGetAfterTradeDates(this.tradeCalBegin, 1)
    )[0];
    const needEndDate = (
      await this.baseDataUtil.utilGetBeforeTradeDates(
        isTradeDayAfterHour(new Date(), 17)
          ? addDay(getNowDate())
          : getNowDate(),
        1,
      )
    )[0];
    // 数据库中数据的时间范围判断: [最小值, 最大值]
    const { startDate, endDate } = (
      await this.dailyRepository.query(
        `select min(trade_date) as startDate, max(trade_date) as endDate from daily;`,
      )
    )[0];
    // case1:数据库没有数据
    if (!(startDate && endDate)) {
      console.log(
        `daily数据填充开始,开始日期: ${needStartDate},结束日期: ${needEndDate}`,
      );
      this.fillDailyData(needStartDate, needEndDate).then(() => {
        console.log(
          `daily数据填充结束,开始日期: ${needStartDate},结束日期: ${needEndDate}`,
        );
      });
    } else if (startDate > needStartDate || endDate < needEndDate) {
      //case2:数据库中数据不全
      if (startDate > needStartDate) {
        console.log(
          `daily数据补充开始,开始日期: ${needStartDate},结束日期: ${subDay(
            startDate,
          )}`,
        );
        this.fillDailyData(needStartDate, subDay(startDate)).then(() => {
          console.log(
            `daily数据补充结束,开始日期: ${needStartDate},结束日期: ${subDay(
              startDate,
            )}`,
          );
        });
      }
      if (endDate < needEndDate) {
        console.log(
          `daily数据补充开始,开始日期: ${addDay(
            endDate,
          )},结束日期: ${needEndDate}`,
        );
        this.fillDailyData(addDay(endDate), needEndDate).then(() => {
          console.log(
            `daily数据补充结束,开始日期: ${addDay(
              endDate,
            )},结束日期: ${needEndDate}`,
          );
        });
      }
    } else {
      const need_trade_dates =
        await this.baseDataUtil.utilGetTradeDatesByRangeDate(
          needStartDate,
          needEndDate,
        );
      const db_trade_dates = (
        await this.dailyRepository.query(
          `SELECT DISTINCT(trade_date) FROM daily WHERE trade_date BETWEEN '${needStartDate}' AND '${needEndDate}';`,
        )
      ).map((obj) => {
        return obj.trade_date;
      });
      const diff_trade_dates = need_trade_dates.filter((trade_date) => {
        return !db_trade_dates.includes(trade_date);
      });
      if (diff_trade_dates.length !== 0) {
        console.log(
          `daily数据补充开始,日期枚举: ${diff_trade_dates.join(',')}`,
        );
        this.fillDailyDataByTradesDates(diff_trade_dates).then(() => {
          console.log(
            `daily数据补充结束,日期枚举: ${diff_trade_dates.join(',')}`,
          );
        });
      } else {
        daily_ready = true;
        console.log(
          `daily数据校验完整,交易日总数:${db_trade_dates.length},开始日期: ${needStartDate},结束日期: ${needEndDate}`,
        );
      }
    }
    await this.cacheManager.set(
      CACHE_KEY_STOCK_DATA_SYNC.DAILY_READY,
      daily_ready,
      {
        ttl: 24 * 60 * 60,
      },
    );
    return daily_ready;
  }
}

复制代码

上述代码看起来稍长,我在这介绍一下主要逻辑:

  • tushare更新数据:对于股票日线行情,tushare会在每天下午五点前更新数据。
  • 自建db完整性校验:对于股票日线行情,service-data会在程序启动时以及每天下午五点前,以日作为单位进行股票日线的完整性校验。
  • 校验缺失数据:数据缺失时,程序会异步填充数据

文章已经很长了,对于更详细的校验逻辑这里就不展开来说了。感兴趣的可以看上面的代码或者github源码。

投资有风险,入市需谨慎~

2.快速响应数据问题

在摸索策略的过程中,我们经常会修改策略参数而后重新回测。这时如果我们对从数据库查询到的数据没有缓存的话,就必然会导致我们每次回测都花费很长时间。为了解决这个问题,service-data引入了内存缓存技术,一些高频数据在第一次查询之后就会加入到缓存当中以备下一次快速响应。对于有些经常使用的数据,比如日线数据,service-data还对这些数据进行了预缓存,方便持续启动后快速运行股票相关策略。

对于这缓存相关逻辑的实现,service-data都维护在了xx.data.ts文件类型中。下面同样以股票日线数据的缓存与预缓存为例,相关代码如下(stock.data.ts):

export const CACHE_KEY = {
  STOCK_BASICS: 'stock_basic',
  STOCK_CODES: 'stock_codes',
  STOCK_ADJS: 'stock_adjs',
  STOCK_ADJ_LASTS: 'stock_adj_lasts',
  STOCK_DAILY_BASICS: 'stock_daily_basics',
  STOCK_DAILY_BASIC_LASTS: 'stock_daily_basic_lasts',
  STOCK_DAILY_BASIC_All: 'stock_daily_basics_all',
  STOCK_DAILYS: 'stock_dailys',
  STOCK_DAILY_LASTS: 'stock_daily_lasts',
  STOCK_WEEKLYS: 'stock_weeklys',
  STOCK_WEEKLY_LASTS: 'stock_weekly_lasts',
  STOCK_MONTHLYS: 'stock_monthlys',
  STOCK_MONTHLY_LASTS: 'stock_monthly_lasts',
};

// private fetch系列抓取数据,get系列提供给外部调用(会利用缓存)
@Injectable()
export default class StockData implements OnApplicationBootstrap {
  token: string;
  tradeCalBegin: string;
  presetCache: string;
  constructor(
    @InjectRepository(StockBasic)
    private stockBasicRepository: Repository<StockBasic>,
    @InjectRepository(AdjFactor)
    private adjFactorRepository: Repository<AdjFactor>,
    @InjectRepository(DailyBasic)
    private dailyBasicRepository: Repository<DailyBasic>,
    @InjectRepository(Daily)
    private dailyRepository: Repository<Daily>,
    @InjectRepository(Weekly)
    private weeklyRepository: Repository<Weekly>,
    @InjectRepository(Monthly)
    private monthlyRepository: Repository<Monthly>,
    @Inject(ConfigService) public readonly configService: ConfigService,
    @Inject(CACHE_MANAGER) public readonly cacheManager: Cache,
  ) {
    const { token, tradeCalBegin, presetCache } =
      this.configService.get('tushare');
    this.token = token;
    this.tradeCalBegin = tradeCalBegin; // 获取量化日历和量化数据的开始日期
    this.presetCache = presetCache;
  }

  async onApplicationBootstrap() {
    // 提高初次缓存速度:主动拉取数据库数据批量缓存,避免过多连接时的查询等待
    await Promise.all([
      this.presetCacheAdjs(),
      this.presetCacheDaily(),
      // this.presetCacheDailyBasic(),  // error:Last few GCs,单独预缓存不报错,3个同时预缓存的话就报错
    ]);
  }

  @Cron('0 15 17 * * 1-5')
  async clearCache() {
    await this.cacheManager.del(CACHE_KEY.STOCK_DAILY_BASICS);
    await this.cacheManager.del(CACHE_KEY.STOCK_DAILY_BASIC_All);
    await this.cacheManager.del(CACHE_KEY.STOCK_DAILY_BASIC_LASTS);
    await this.cacheManager.del(CACHE_KEY.STOCK_ADJS);
    await this.cacheManager.del(CACHE_KEY.STOCK_ADJ_LASTS);
    await this.cacheManager.del(CACHE_KEY.STOCK_DAILYS);
    await this.cacheManager.del(CACHE_KEY.STOCK_DAILY_LASTS);
  }
  
  // ...股票其它数据的逻辑

  async presetCacheDaily() {
    const beginTime = Number(new Date());
    const haveStoreForDaily = await this.cacheManager.get(
      CACHE_KEY_STOCK_DATA_SYNC.DAILY_READY,
    );
    console.log(`开始预缓存daily`);
    if (haveStoreForDaily) {
      const cacheStockDailys = {};
      const cacheStockDailyLast = {};
      const tsCodeDailysMap = {};
      const storeDaily = (await this.dailyRepository.query(`
      SELECT * FROM daily WHERE trade_date >= ${this.tradeCalBegin} ORDER BY trade_date DESC;`)) as DailyResult[];
      storeDaily.forEach((dailys) => {
        const ts_code = dailys.ts_code;
        if (!tsCodeDailysMap[ts_code]) {
          tsCodeDailysMap[ts_code] = [];
        }
        tsCodeDailysMap[ts_code].push(dailys);
      });
      Object.keys(tsCodeDailysMap).forEach((ts_code) => {
        const curTsCodeDailys = tsCodeDailysMap[ts_code];
        const lastDaily = curTsCodeDailys[0];
        cacheStockDailyLast[ts_code] = lastDaily;
        const dailysMap = {};
        curTsCodeDailys.forEach((adj) => {
          dailysMap[adj.trade_date] = adj;
        });
        cacheStockDailys[ts_code] = dailysMap;
      });
      await this.cacheManager.set(CACHE_KEY.STOCK_DAILYS, cacheStockDailys, {
        ttl: 24 * 60 * 60,
      });
      await this.cacheManager.set(
        CACHE_KEY.STOCK_DAILY_LASTS,
        cacheStockDailyLast,
        {
          ttl: 24 * 60 * 60,
        },
      );
      console.log(
        `预缓存daily结束,缓存开始日期:${this.tradeCalBegin},耗时:${
          Number(new Date()) - beginTime
        }`,
      );
    }
  }

// 拉取复权日线数据
  private async fetchStockDailyByTsCodeAfterAdj(ts_code: string): Promise<{
    stockDaily: { [trade_date: string]: DailyResult };
    lastStockDaily: DailyResult;
  } | null> {
    let dailys;
    const haveStoreForDaily = await this.cacheManager.get(
      CACHE_KEY_STOCK_DATA_SYNC.DAILY_READY,
    );
    const pArr = [];
    if (haveStoreForDaily) {
      pArr.push(
        this.dailyRepository.find({
          where: { ts_code, trade_date: MoreThanOrEqual(this.tradeCalBegin) },
        }),
      );
    } else {
      // dailys = await getDaily(this.token, {
      //   ts_code,
      //   start_date: this.tradeCalBegin,
      // });
      pArr.push(
        getDaily(this.token, {
          ts_code,
          start_date: this.tradeCalBegin,
        }),
      );
    }
    // const { adjFactor, lastAdj } =
    //   (await this.getAdjFactorByTsCode(ts_code)) || {};
    let adjFactor, lastAdj;
    pArr.push(this.getAdjFactorByTsCode(ts_code));
    await Promise.all(pArr).then((datas) => {
      dailys = datas[0];
      const curAdjs = datas[1] || {};
      adjFactor = curAdjs.adjFactor;
      lastAdj = curAdjs.lastAdj;
    });
    if (!(dailys && adjFactor)) {
      return null;
    }
    const lastStockDaily = dailys[0];
    const dailyMap = {};
    dailys.forEach((daily) => {
      dailyMap[daily.trade_date] = daily;
    });
    // 对每个交易记录进行前复权(调用adjStockRow直接修改原对象,不需要重新赋值)
    Object.keys(dailyMap).forEach((trade_date) => {
      const curDaily = dailyMap[trade_date];
      const curAdjFactor = adjFactor[trade_date];
      this.adjStockRow(curDaily, curAdjFactor, lastAdj);
    });
    this.adjStockRow(
      lastStockDaily,
      adjFactor[lastStockDaily.trade_date],
      lastAdj,
    );
    return {
      stockDaily: dailyMap,
      lastStockDaily: lastStockDaily,
    };
  }

  public async getStockDailyByTsCodeAfterAdj(ts_code: string): Promise<{
    stockDaily: { [trade_date: string]: DailyResult };
    lastStockDaily: DailyResult;
  } | null> {
    // 先从缓存中拿
    const cacheStockDaily =
      (await this.cacheManager.get(CACHE_KEY.STOCK_DAILYS)) || {};
    const cacheStockDailyLast =
      (await this.cacheManager.get(CACHE_KEY.STOCK_DAILY_LASTS)) || {};
    if (cacheStockDaily[ts_code] && cacheStockDailyLast[ts_code])
      return {
        stockDaily: cacheStockDaily[ts_code],
        lastStockDaily: cacheStockDailyLast[ts_code],
      };
    const { stockDaily, lastStockDaily } =
      (await this.fetchStockDailyByTsCodeAfterAdj(ts_code)) || {};
    if (!stockDaily) {
      return null;
    }
    // 缓存
    cacheStockDaily[ts_code] = stockDaily;
    await this.cacheManager.set(CACHE_KEY.STOCK_DAILYS, cacheStockDaily, {
      ttl: 24 * 60 * 60,
    });
    cacheStockDailyLast[ts_code] = lastStockDaily;
    await this.cacheManager.set(
      CACHE_KEY.STOCK_DAILY_LASTS,
      cacheStockDailyLast,
      {
        ttl: 24 * 60 * 60,
      },
    );
    return {
      stockDaily,
      lastStockDaily,
    };
  }
}

复制代码

上面代码比较长,我在这里介绍一下主要逻辑过程:

  • 程序启动或者调用者拉取数据
  • 程序判断缓存当中有无相关数据,有则返回,无则走接下来流程
  • 程序判断daily数据在自建db中是否完整(程序启动时完整校验)
  • 完整从自建db中拉取数据,不完整从tushare平台拉取数据
  • 拉取数据后存储进入缓存
  • 复权后返回

好的,经过上面对service-data解决快速大量返回响应数据的分享完成之后,我们进入最后一个,即通知服务的分享。

四:贴心的通知服务(service-notify)

这个模块就简单啦。当前实现了邮件通知功能,写了一个回测通知邮件和选股通知邮件的方法。没啥重要逻辑,具体代码也很简单清晰,阁下有兴趣的话直接查看service-notify的源码吧,github地址:github.com/iamjwe/shar…

文章那么长,如果您能够自上而下看到这里,我必须向您表示感谢,阿里嘎多~

行文不易,欢迎点赞~

历史文章:

# 看懂此文,手写十种Promise!

# Typescript也许应该这样入门才对

# 熟悉ES6,这些内容起码得懂个七八十吧

# 帮助你建立前端脚手架的系统化认识

# 带你揭开自动化构建的神秘面纱

# 源码级别人话说:Virtual DOM和DOM diff算法

# 带你领略Eslint代码检查的四种姿势

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改