前端会用标签模板(Tagged Templates)就能当股神

前端会用标签模板(Tagged Templates)就能当股神

文因互联的问答机器人里有一个小功能,叫「金融小逻辑」,可以利用文因互联拥有的结构化的财务数据生成财经箴言

利润,充满估计和假设;现金,才是实打实的硬通货净利润不等于挣到的钱,两者可以有很大的差别。净利润一定要和现金流量表的经营现金流净额对照着看,投资者可以直接用经营现金流净额 / 净利润来衡量净利润的含金量。万科2017年三季报表明,万科的2016年现金比率为1.2,比值大于1,净利润含金量比较高,现金流入量较大,净利润质量较好,推荐关注。

这不禁让人浮想联翩,人类擅长定性分析,而机器擅长定量分析,当一位富有智慧的人拿到金融小逻辑给出的各家公司近几年的CAGR(复合年均增长率),他就可以做很多事了,比如拿着机器给出的箴言去炒股群里指点江山获得高人一等的装逼快感。


金融支持小姐姐最近在维护 @财报妹 这个微博账号,她希望在机器提取完财务数据后,可以用这些数据自动生成金融分析。这样就可以抓住年报季的热点,第一时间发布上市公司财务情况。

虽然她在学校的专业是金融学,但她并没有选择自己去读财报来指点江山——机器完成数据提取才不到10秒,金融小姐姐要把数据手动复制到分析模板里,一些指标还需要查找前三年的数值,人工搞完至少要两个多小时。这样就无法第一时间抢先发布,那还怎么涨粉?

所以最好还是让机器来读财报,选出有意思的公司,并计算各种导出值,按照小姐姐的「智慧箴言」自动生成分析就行。

但是问答机器人里的金融小逻辑引擎应用场景是实时问答,所以为了效率牺牲了一定的可配置性,有比较多的限制,无法灵活地输出小姐姐的智慧金句。

她听说文因互联有一位前端工程师写文档写得比较灵活,他可能也懂一些技术吧,就来向他请教怎么解除这些限制,让她能指点江山的时候能放飞自我。

前端小哥一听需求,就明白她需要的是一个模板引擎,「那就来学 React 吧」,他建议道。但小姐姐不学 React。前端小哥想,有什么模板引擎是不用学就会用的呢?存在这样的模板引擎吗?

不学就会的模板引擎还是存在的,那就是 Tagged Template Literals,前端圈里叫它模板字符串标签,而在财经圈子里它却被尊称为「股神引擎」。只要在两个 ` 之间写上一些公式和滥竽充数的废话,再加上一点魔法,网页上就会自动显示出充满智慧的财经箴言,你就能指点江山当股神!就像这样:

import { 非经常收益, 净利润扣非, 上期净利润扣非 } from './variables';

template`
  发展能力分析
    2017年非经常收益为${非经常收益},扣除掉非经常收益的净利润为${净利润扣非}    而上期扣除非经常收益的净利润为${净利润扣非}${充满智慧的结论1}`;

其中非经常收益、净利润扣非等变量是从一个财经常用变量表里引入的,底下模板中用到哪个的话得先在上面引入。

充满智慧的结论1是通过对经济学的多年学习,加上对市场的深刻理解,还有前端工程师一分钟的语法指导得来的。大概长这样:

const 充满智慧的结论1 = logic`
do {
  if (${净利润扣非} > ${上期净利润扣非}) { '净利润较去年增加,真不错快买' }
  else { '净利润较上期减少,快向你的朋友推荐这只股票' }
}
`;

小姐姐表示这还蛮好理解的,因为两个花括号里的字符串就是会填到文本里的内容,而前面的 if else 也不难理解,别的部分都是一模一样的,不需要深入理解也没事。因此她很快就写出了一大堆逻辑和模板,准备要开始发微博了。

但作为提供那「一点魔法」的前端,还是得深入理解 template 和 logic 这两个标签里到底发生了啥。

模板标签简介

经常使用 styled-components 或者 graphql-tag 的人,可能都已经熟悉模板字符串标签的工作原理。此处仅用《 The magic behind styled-components 》 中的例子简单概括一下。

以这样一个函数为例:

const logArgs = (templates: string[], ...args) => console.log(templates, args)

我们可以直接把它当做模板字符串标签来使用:

const favoriteFood = 'pizza'

logArgs`I like ${favoriteFood}.`

这时候模板中的字面量(「I like 」和「.」)会被传到 logArgs 的第一个参数 templates 里,而把字面量隔开的那些模板变量会被收集进第二个参数 args 里:



那么如果我们想把字面量和变量拼起来,就得像拉拉链一样把它们拉到一起,做这种事用 reduce 最方便:

const result = strings.reduce(
  (prev, next, i) => `${prev}${next}${args[i]}`,
  ''
);

这样,把 result 返回之后,调用模板标签就能拿到 I like pizza. 了。

所以这个项目非常简单,用 create-react-app 新建一个项目,往 app.js 里加一个模板字符串就搞定了。

以上就是股神引擎的全部内容。

收集数据依赖

当然,还有一个也有一点重要的问题要解决,就是「净利润扣非」其实等于「净利润 - 非经常收益」,而非经常收益又是「营业外收入 - 营业外支出」,其中净利润、营业外收入和营业外支出三个基本数据是数据提取组能从财报 PDF 中提取得到的。

净利润扣非」不是直接提取到的数据,所以要得到「净利润扣非」这个导出值,就得向后端一次性请求所有这些基本数据,在前端进行计算后返回再填入模板。

构造公式图

注意到所有公式中的变量其实组成了一个有向无环图(DAG),只要收集所有叶子节点的名字,就可以一次性向后端请求它们对应的值了。后端返回数据后,把叶子节点的值填入 DAG 中,就能一级级算出根部的净利润扣非的值了。



因此我们把公式表示成这样的数据结构:

// formulas.json
{
    "净利润扣非": {
        "formula": "净利润 - 非经常收益",
        "unit": "亿元"
    },
    "净利润": {
        "unit": "亿元"
    },
    "净利润": {
        "unit": "亿元"
    }
}

然后在 template 模板字符串标签里把这个 JSON 中的每一项都变成一个节点,最终把这些节点根据公式连接起来就变成一个 DAG 啦:

/** 用于表示公式(左值)或者公式里的变量(右值)的节点类 */
class FormulaVariableNode {
  children: { [name: string]: FormulaVariableNode };
  addChild(node: FormulaVariableNode) {}

  /** 载入左值(name)和公式 */
  constructor(name: string, formula: ?string) {}

  /** 计算某个变量依赖哪些基本变量 */
  get relatedName() {
    if (size(this.children) === 0) return this.name;
    return _.flattenDeep(Object.values(this.children).map(child => child.relatedName));
  }
  
  /** 递归地计算值,如果已经是叶子节点那就返回自己的值 */
  get value() {}

  /** 用于设置叶子节点的值 */
  set value(valueFromDB: number) {}
}

连接节点的过程就是简单地遍历 formulas.json 把每个公式中的右值对应的 DAG Node 用addChild 塞进左值对应的 DAG Node。

用 VM 做计算

而当节点连接完毕,值也填进了每个叶子节点,就可以用 value 这个 getter 来递归计算父节点的值。其中递归步骤特别简单,就是一句:

// import vm from 'vm-browserify';
const calculatedValue = vm.runInNewContext(
  this.formula, _.mapValues(this.children, child => child.value)
);

vm 可以看做是 NodeJS 上一个沙箱化的 eval,它的第二个参数接受一个对象,键会变成沙箱中的全局变量名,值就是这些全局变量的值。所以此处 vm 会直接根据公式算出结果。

Dataloader

那么怎么向后端请求叶子节点所需的值呢?如果模板中只用了一个变量,那么这个变量所依赖的基本变量的名字列表可以用relatedName getter 来递归地拿到。

但模板中可能会涉及到十几个变量,他们很可能会交叉依赖共同的基本变量,所以我们需要先去重,然后再一次性向后端请求所有基本变量。

为什么一直强调「一次性请求」,因为模板是可以嵌入逻辑模板的,逻辑模板中也可以使用变量,然后返回一个结果。但我们不希望每个逻辑模板自己会产生一个异步的数据请求,金融支持小姐姐想要一次性看到结果,还有后端目前的设计中一次性取数据更快,所以我们期待整个模板加上所有逻辑模板里的变量依赖收集完后一次性向后端请求。

一见「一次性向后端请求」,立刻想到 GraphQL,立刻想到 DataLoader。前端工程师的想象惟在这一层能够如此跃进。

GraphQL 是 Facebook 提出的为前端组件一次性加载所有所需数据的方案,而 DataLoader 一开始是用于合并 GraphQL 服务端向数据库的请求的,其原理是在内部维护一个缓存数组,每个需要数据的函数去调用 DataLoader 的时候,其实是把请求参数缓存在数组里,然后在 process.nextTick 中就能拿到上个事件循环中所有数据请求的请求参数了,此时再把它们拼接起来,一次性请求掉。

比如下面这个用于去抖加载所有变量值的 DataLoader,DataLoader 接受一个函数,它会把它在上个 tick 收集到的所有请求参数传给这个函数,此处我们就能拿到所有地方调用这个 variableValueLoader 时传入的参数,它们被合并成了数组 variableNames

/** 用于去抖加载所有变量值的加载器 */
const variableValueLoader: DataLoader<string, DataItemFromServer | ?number | ?string> = new DataLoader(
  /** 拿到了所有请求的变量 */
  async (variableNames: string[]): ?(number[]) => {
    /** 根据公式,递归获取请求的变量会涉及到的基本变量的变量名 */
    const atomVariableName = getLeaves(variableNames, formulas);
    /** 一次性加载数据! */
    const atomVariableValues = await fetchVariableValue(atomVariableName);
    // 往 DAG 里面塞值、计算
    ...
    // 返回所有模板涉及到的变量的值
    return ...
  }
);

就像用了开塞露一样,所有的变量一次性涌出,无比地顺滑!

当然这也带来了一个问题,就是我们的每个变量都变成了一个函数调用:

/** DataLoader 会把传入的变量名收集起来 */
export const v = (variableName: string) => (loader: DataLoader) => loader.load(variableName);

这也的话写模板的时候就得这样写 ↓,多一个莫名其妙的 v,而且变量名还得是字符串,得多打一对引号很累很伤手:

template`
  大扎好,我系${v('明星名1')},我四${v('明星名2')}${v('游戏的名字')},介四里没有挽过的船新版本
`;

解决了数据获取和计算的问题,我们还需要实现当初的诺言:「这个模板引擎用起来很简单的,你把要填在那的变量填在 import 后面,然后填进模板就好了」,所以我打算这样预先把每个变量都用上面的函数调用一下,帮小姐姐保养她的玉手:

export const 明星名1 = v('明星名1');
export const 明星名2 = v('明星名2');
export const 游戏的名字 = v('游戏的名字');

生成可引用的变量

然而公式中的变量实在是有点多,因为它是文因互联代代相传的传家公式表,像一个种马家族绵延五百年产生的族谱一样长:



(图:经马赛克处理,公式表的二十分之一,显示在 VsCode 的小地图里)

一个个搞起来太累了,而且手写这些 export const 的话还得两头维护,没有一个 single source of truth。

这时候一句广告语响了起来:「工作劳累,身心疲惫,何不趁这春光明媚,把 codegen 学会。」

babel-plugin-codegen 和 babel-plugin-preval 是前端在编译期运行业务代码的两个途径,一般可以使用它们的宏版本, codegen.macro 和 preval.macro 。用它们可以直接在模板字符串里写代码,并返回一个值,前者会用返回的字符串直接生成代码,后者则能把返回的值赋给某个运行期变量等。

此处我们简单使用 codegen.macro,在模板字符串里引入 formulas.json,对于其中的每一个键,都生成一个 export const:

import codegen from 'codegen.macro';

// 把公式变量预先用 v 函数调用一遍,方便引用,减少代码量
codegen`
const fs = require('fs');

const formulasJSON = JSON.parse(fs.readFileSync(require.resolve('../formulas.json'), 'utf8'));
const variableNames = Object.keys(formulasJSON);

module.exports = variableNames
  .map(
    variableName =>
      'export const ' + variableName.replace(/[()、:()]/g, '') + ' = loader => loader.load("' + variableName + '")'
  )
  .join(';');
`;

在编译后,这段代码将展开成巨长的 export 列表。

然后在别的地方就能直接 import 处理后的变量了:

import { 非经常收益, 净利润扣非, 上期净利润扣非 } from './variables';

可惜由于 babel 和 ESModule 的限制,目前还没法实现一次性自动 import 所有的变量,所以还得手写 import,当然,这种限制也是有其道理的。

简化的逻辑

在我们的模板中除了可以嵌入变量以外,还可以嵌入逻辑,逻辑就是任意的一段 JS 表达式,表达式的值会插入到模板中。

目前需要写的模板都比较简单,只需要简单条件判断的表达能力,所以一开始我们推荐使用三目操作符(? : ),但金融支持小姐姐说这是乱码看不懂

那就只好换一种表达式写法了,在 ECMAScript 中表达式就那么几种,其中比较易读的应该就是 do-expression 了:

const exampleLogic = logic`
do {
  if(${销售利润率} > 1.2 && ${营业收入} < 700000) { '其实还是蛮高的' }
  else if(${销售利润率} < 0.5) { '不是很高' }
  else { '一般般' }
}
`;

template`
  2017年营业利润为${营业利润}元,营业收入为${营业收入}元,所以销售利润率为${销售利润率}他们的销售利润率${exampleLogic}  ${logic`Math.random() > 0.5 ? 幽默笑话1 : 幽默笑话2`}
`;

我们用 @babel/standalone 来在运行时转换 do-expression,再交给 vm 来执行。

const { code: transformedCode } = transform(resultCode, {
  presets: ['stage-0'],
});

try {
  return vm.runInNewContext(transformedCode);
} catch (error) {
  return '<error>';
}

其中 resultCode 来自

const resultCode = strings.reduce(
  (prev, next, i) => `${prev}${next}${resultValuesOfVariables[i] || ''}`,
  '""+'
);

可以看到这段代码也是一个 reduce 的过程,把字面量数组 strings 和变量数组 resultValuesOfVariables 像拉拉链一样拼在一起。

只不过在 reduce 过程的开头,我们放上了一个 ""+ ,这是什么意思呢?

就像我们也偶尔会看到 +function(){}() ,JS 里加号的含义就像中文里的「搞」一样多,此处 ""+ 的意思就是「搞后面的东西」,也就是「把后面的东西当做表达式」,不然 babel 会认为这是一个 do…while statement,而认不出它是一个 do-expression,会报错:

Unexpected token, expected while (5:1)
  3 |   else if(0.732 < 0.5) { '不是很高' }
  4 |   else { '一般般' }
> 5 | }

当然,do-expression 的语法还不稳定,在其 Github 仓库的 issue 里还进行着激烈的讨论,此处也仅是在小项目中为了简化判断逻辑的输入,因地制宜地使用了一下。

至此,一个收集模板中的所有变量加上逻辑中用到的所有变量,一次性请求数据,并根据公式计算结果填入模板的小项目就可以投入使用了。小姐姐也在不知不觉中用上了 ES6

但是小姐姐还有别的需求

文因互联有自己的金融知识图谱,里面很多数据还需要找到有创意的使用方式和工程实现,在前端呈现出来。

如果你熟悉前端目前的技术栈,不但能快速阅读已有项目代码,了解涉及到的 npm 包,也能在现有数据上提出有创意的工程方案,欢迎来找金融支持小姐姐听新的好玩的需求

简历请投递:hr@memect.co 绽放你的工程能力吧!

职位细节可以在此处查看。

编辑于 2018-05-04 17:42