基于DolphinDB的因子计算最佳实践(下)

6. 因子回测和建模

很多时候,计算因子只是投研阶段的第一部分,而最重要的部分其实在于如何挑选最为有效的因子。在本章节中,将会讲述如何在DolphinDB中做因子间的相关性分析,以及回归分析。

6.1 因子回测

因子的建模和计算等,一旦从图表上分析出有方向性的结论,就要做成策略。按照确定的因子信号来设计出来的一套买卖条件,就是所谓的投资策略。把一套投资策略代入到历史数据当中,计算按照这样的策略条件去做交易是否长期有利可图的过程就是回测。

事件驱动型回测主要用来分析少量标的,中高频的交易策略。在按因子配置投资组合的策略类型中不是核心或重点,这里不做详细阐述。

本章主要介绍的案例是向量化的因子回测。

首先,在k线数据上,实现了一个按多日股票收益率连乘打分的因子。之后根据分值排序高低分配标的持仓权重。

得到分配持仓权重后,再与持仓股票的日收益率做矩阵乘法,最后按天相加,可得整个投资组合的回报率变化曲线。

完整实例代码参考:向量化因子回测完整代码

6.2 因子相关性分析

在之前的章节中,存储因子的库表可以是多值模型,也可以是单值模型。在求因子间相关性时,推荐利用 array vector 将同一股票同一时间的多个因子放在一个列中,这样可以避免枚举多个列名。下面以单值模型为例,演示如何有效地先在股票内求因子间相关性,然后根据股票个数求均值。

  • 单值模型计算因子间自相关性矩阵 其原理是先将当天的因子根据时间和标的,转换成 array vector ,再对生成的小内存表进行计算求值。

    day_data = select toArray(val) as factor_value from loadTable("dfs://MIN_FACTOR_OLAP_VERTICAL","min_factor") where date(tradetime) = 2020.01.03 group by tradetime, securityid result = select toArray(matrix(factor_value).corrMatrix()) as corr from day_data group by securityid corrMatrix = result.corr.matrix().avg().reshape(size(distinct(day_data.factorname)):size(distinct(day_data.factorname)))

6.3 多因子建模

在大部分场景中,多因子投资模型的搭建可分为:1,简单加权法;2,回归法;两种方式均可以在 DolphinDB 中实现。

  • 简单加权法

    对不同的因子不同的权重,计算出所有因子预测的各只股票的预期回报率的加权平均值,然后选择预期回报率最高的股票。这类方法比较简单,故不在本小节赘述。

  • 回归法

    在DolphinDB中,有很多相关的内置函数。细节使用请参考文档:DolphinDB教程:机器学习

    其中对于线性回归内置了多种模型,包括普通最小二乘法回归(OLS Regression),脊回归(Ridge Regression),广义线性模型(Generalized Linear Model)等。 目前,普通最小二乘法回归 olsEx,脊回归 ridge 中的 'cholesky' 算法,广义线性模型 glm 都支持分布式并行计算。

    其他回归模型,DolphinDB 支持 Lasso 回归,ElasticNet 回归,随机森林回归,AdaBoost 回归等。其中,AdaBoost 回归 adaBoostRegressorrandomForestRegressor 支持分布式并行计算。

7. 因子计算的工程化

在实际量化投研过程,研究员要聚焦策略因子研发,而因子计算框架的开发维护通常是IT部门人员来负责,为了加强协作,通常要进行工程化管理。好的工程化管理能减少重复、冗余工作,极大的提高生产效率,使策略投研更加高效。本章节将会通过一些案例来介绍如何对因子计算进行工程化管理。

7.1 代码管理

因子的开发往往涉及到QUANT团队和IT团队。QUANT团队主要负责因子开发和维护因子逻辑代码。IT团队负责因子计算框架的开发和运维。因此要把计算框架的代码和因子本身的逻辑代码做到有效的分离,降低耦合,并且可以支持因子开发团队单独提交因子逻辑代码,计算框架能够自动更新并进行因子重算等任务。本节我们主要讨论因子逻辑代码管理,计算框架和运维请参考7.3和7.6。

我们推荐用户使用自定义函数来封装核心的因子逻辑,每个因子对应一个自定义函数。DolphinDB对自定义函数的管理提供了两种方法,函数视图(Function View)和模块(Module)。函数视图的优点包括:(1)集中管理,添加到集群后,所有节点都可以使用;(2)支持权限管理。函数视图的主要缺点是无法进行模块化管理,当数量增加时,运维难度增加。模块的优缺点正好同函数视图相反。模块可以将大量函数按目录树结构组织在不同模块中。既可以在系统初始化时预加载,也可以在需要使用的时候使用use语句,引入这个模块。但是模块必须复制到每个需要使用的节点才可以使用,另外无法对模块中的函数进行权限管理。后续版本会统一函数视图和模块的优点。

7.2 单元测试

遇到因子代码重构、计算框架调整、数据库升级等情况,必须对最基本的因子逻辑进行正确性测试。DolphinDB内置了单元测试框架,可用于自动化测试。

这个单元测试框架主要包含了以下内容:

  • test函数,可以测试一个单元测试文件或一个目录下的所有单元测试文件。
  • @testing宏,用于描述一个测试case。
  • assert语句,判断结果是否符合预期。
  • eqObj等函数,用于测试结果是否符合预期。

下面通过对因子函数factorDoubleEMA的测试来展示单元测试的撰写。全部代码请点击这儿查看。下面的代码展示了三个测试cases,两个用于批处理,一个用于流计算处理。

@testing: case = "factorDoubleEMA_without_null"
re = factorDoubleEMA(0.1 0.1 0.2 0.2 0.15 0.3 0.2 0.5 0.1 0.2)
assert 1, eqObj(re, NULL NULL NULL NULL NULL 5.788743 -7.291889 7.031123 -24.039933 -16.766359, 6)

@testing: case = "factorDoubleEMA_with_null"
re = factorDoubleEMA(NULL 0.1 0.2 0.2 0.15 NULL 0.2 0.5 0.1 0.2)
assert 1, eqObj(re, NULL NULL NULL NULL NULL NULL 63.641310 60.256608  8.156385 -0.134531, 6)

@testing: case = "factorDoubleEMA_streaming"
try{dropStreamEngine("factorDoubleEMA")}catch(ex){}
input = table(take(1, 10) as id, 0.1 0.1 0.2 0.2 0.15 0.3 0.2 0.5 0.1 0.2 as price)
out = table(10:0, `id`price, [INT,DOUBLE])
rse = createReactiveStateEngine(name="factorDoubleEMA", metrics=<factorDoubleEMA(price)>, dummyTable=input, outputTable=out, keyColumn='id')
rse.append!(input)
assert 1, eqObj(out.price, NULL NULL NULL NULL NULL 5.788743 -7.291889 7.031123 -24.039933 -16.766359, 6)
复制代码

7.3 并行计算

到现在为止,我们讨论的都是因子的核心逻辑实现,尚未涉及通过并行计算或分布式计算来加快计算速度的问题。在因子计算的工程实践中,可以通过并行来加速的维度包括:证券(股票),因子和时间。在DolphinDB中,实现并行(或分布式)计算的技术路径有以下4个途径。

  • 通过SQL语句来实现隐式的并行计算。当SQL语句作用于一个分布式表时,引擎会尽可能下推计算到各个分区执行。
  • 创建多个数据源(data source),然后使用mr函数(map reduce)来实现并行计算。
  • 用户通过submitJobsubmitJobEx提交多个任务。
  • 用peach或ploop实现并行。

我们不建议在因子计算中采用peach或ploop的方式来实现并行。DolphinDB中可用于计算的线程分为两类,分别称之为worker和executor。一般worker用于接受一个任务(job),并将任务分解成多个子任务(task)在本地的executor或远程的worker上执行。一般executor执行的都是本地的耗时比较短的子任务,也就是说在executor上执行的任务一般不会再分解出子任务。peach或ploop将所有的子任务都在本地的exeuctor执行。如果子任务本身再分解出子任务(譬如子任务是一个分布式SQL Query),将严重影响整个系统的吞吐量。

下面我们讨论前三种方法在因子的并行计算中的应用。

7.3.1 分布式SQL

分布式SQL的第一个应用是计算无状态的因子。对于无状态的因子,即计算本身可能只涉及单条记录内一个或者几个字段。这样的计算可以利用分布式表的机制,在各分区内并行计算。

第三章中的权重偏度因子为例,此因子计算只用了一个字段,且计算逻辑不涉及前后数据,所以在SQL中调用时,DolphinDB会自动在各分区内并行计算。如果目标数据是内存表,可以使其变为内存分区表,使之分布式并行计算。内存分区表的创建,参考createPartitionedTable

resWeight =  select TradeTime, SecurityID, `mathWghtSkew as factorname, mathWghtSkew(BidPrice, w)  as val from loadTable("dfs://LEVEL2_Snapshot_ArrayVector","Snap")  where date(TradeTime) = 2020.01.02
复制代码

分布式SQL的第二个应用场景是计算按标的分组的时序相关因子。对于组内计算的因子,在SQL模式中,将组字段设为分区字段,可以用context by组字段并行。如若计算涉及到的数据不跨分区,则可以用 map 语句,加速结果输出。如若计算涉及到的数据跨分区,则SQL会在分区内并行计算,最后在结果部分检查再合并。

日内收益率偏度的因子 dayReturnSkew 计算为例, 这个计算本身是需要对标的分组,在组内每天分别做计算。涉及到的数据为分钟频数据,数据源是按月分区,标的 HASH 3 分区。因此,我们在做计算的时候除了可以用context by组字段并行以外,还可以用 map 语句加速输出结果。

minReturn = select `dayReturnSkew as factorname, dayReturnSkew(close) as val from loadTable("dfs://k_minute_level", "k_minute") where date(tradetime) between 2020.01.02 : 2020.01.31 group by date(tradetime) as tradetime, securityid map
复制代码

7.3.2 map reduce

当用户不想根据分区做并行计算时,可以通过mr函数自定义做并行计算。

以第三章中介绍的factorDoubleEMA因子为例。DoubleEMA因子的计算是对标的分组,在组内连续做窗口计算。此类计算由于将窗口的划分会跨时间分区,所以在SQL计算中会先在分区内做计算,然后最后合并再做一次计算,耗时会比较长。

更合理的做法是,如果分区只按照标的分区,那么计算就可以直接在分区内做完而不用合并检查最终结果了。此时可以用 repartitionDS 函数先将原本的数据重新分区再通过map reduce的方式做并行计算。

//将原数据按股票重新10个HASH分区
ds = repartitionDS(<select * from loadTable("dfs://k_minute_level", "k_minute") where date(tradetime) between 2020.01.02 : 2020.03.31>, `securityid, HASH,10)

def factorDoubleEMAMap(table){
	return select tradetime, securityid, `doubleEMA as factorname, factorDoubleEMA(close) as val from table context by securityid map
}

res = mr(ds,factorDoubleEMAMap,,unionAll)
复制代码

7.3.3 通过submitJob提交任务

之前的两种并行计算都是在前台执行的,并行度是由参数 localExecutors 设置。而有些作业可能很大,或者用户不想影响前台使用,此时可以通过 submitJob 提交任务。submitJob的并行度由 maxBatchJobWorker 参数设置。由于后台作业之间是独立的,通常不需要返回到前端的任务都推荐用后台提交 submitJob 的形式。

仍旧以 dayReturnSkew 因子为例。通常我们是需要将因子写入因子库表的,此时可以将整一个过程提交几个后台作业去执行,而在客户端中,同时可以继续做其他计算。由于此例存入的因子库的分区是按月和因子名VALUE分区,故此时应按照月份去提交作业。这样既可以并行写入不会冲突,又可以将作业提交到后台,不影响前台提交其他任务。

def writeDayReturnSkew(dBegin,dEnd){
	dReturn = select `dayReturnSkew as factorname, dayReturnSkew(close) as val from loadTable("dfs://k_minute_level", "k_minute") where date(tradetime) between dBegin : dEnd group by date(tradetime) as tradetime, securityid
	//写入因子库
	loadTable("dfs://K_FACTOR_VERTICAL","factor_k").append!(dReturn)
	}

for (i in 0..11){
	dBegin = monthBegin(temporalAdd(2020.01.01,i,"M"))
	dEnd = monthEnd(temporalAdd(2020.01.01,i,"M"))
	submitJob("writeDayReturnSkew","writeDayReturnSkew_"+dBegin+"_"+dEnd, writeDayReturnSkew,dBegin,dEnd)
}
复制代码

7.4 内存管理

内存管理一直是运维人员和研究人员关注的重中之重,本节将从批和流两个角度简单介绍如何在DolphinDB中高效地使用内存。更多有关内存管理的详细内容,请参阅DolphinDB内存管理教程

在配置 DolphinDB 环境时,计算和事务的内存占用可在单节点的 ”dolphindb.cfg” 或集群的 cluster.cfg 中,通过参数”maxMemSize“配置单节点最大可用内存。

  • 批处理的内存管理

    章节3.2中的例子,若对半年的快照数据做操作,批处理方式的中间变量占用内存达到21GB,如果设置的内存小于21GB,则会报Out of Memory错误。这种情况下可以将作业拆分后再提交。

    在调试大任务量的计算完成后,可通过 undef 函数将变量赋值为 NULL,或者关闭 session 来及时释放变量的内存。

  • 流计算的内存管理

    章节4.2中的例子,代码中对中间流表调用了函数 enableTableShareAndPersistence 以持久化,指定缓存开始为80万行。当流表数据量超过80万行时,旧的数据会持久化到磁盘上,以空出内存里的空间供新数据写入,这样该流表就可以连续处理远远超过80万行的数据。

7.5 权限管理

因子数据是非常重要的数据,一般来说,用户并不能随意访问所有因子,因此需要对因子数据做好权限管理。DolphinDB database 提供了强大、灵活、安全的权限控制系统,可以满足因子库表级,函数视图级的管理。更多有关权限管理的详细内容,请参考权限管理教程

在实际的生产中通常使用以下三种管理方式:

  • 研发人员是管理员,完全掌握数据库

这种情况可以授予研发组 DB_OWNER 的权限(创建数据库并管理其创建的数据库的权限),使其可以自行创建数据库、表,并对自己创建的数据、表进行权限管理。

login("admin", "123456");
createUser("user1", "passwd1")
grant("user1", DB_OWNER)
复制代码
  • 运维人员管理数据库,研发人员只有库表的读写权限

这种情况,数据库管理人员可以将数据表的权限授予给因子研发人员,或者创建一个group组,将权限授予这个组,再将需要权限的人员添加到这个组中统一进行管理。

//以用户的方式进行授权
createUser("user1", "passwd1")
grant("user1", TABLE_READ, "dfs://db1/pt1")

//以group的方式进行授权
createGroup("group1name", "user1")
grant("group1name", TABLE_READ, "dfs://db1/pt1")
复制代码
  • 研发人员只可读部分而非全库表数据权限

DolphinDB 本身并不直接支持表内数据级的权限控制,但是通过DolphinDB本身灵活的权限控制,我们可以通过其他方式来实现表内数据级的权限控制。
这里我们可以通过对用户授予functionview 权限 VIEW_EXEC 这种方式来实现表内数据级的权限控制。
完整代码参考:章节附件7.5.3 因子表权限控制。通过这份代码,用户"u1"虽然没有表的读权限,但是可以获得表内factor1因子的数据。

//创建用户u1,我们想授予u1 只能读取因子factor1的权限
createUser("u1", "111111")
//定义只取因子的函数
def getFactor1Table(){
    t=select * from loadTable("dfs://db1","factor") where factor_name="factor1";
    return t;
}
//将函数保存到系统中
addFunctionView(getFactor1Table)
//将该函数权限授予用户u1
grant("u1", VIEW_EXEC, "getFactor1Table");
//注意新授予的权限,用户需要重新登录才能加载

factor1_tab=getFactor1Table()
复制代码

7.6 任务管理

因子计算的任务通常分为全量计算所有因子任务、交互式单因子重算任务、所有因子增量计算任务这三种,本章会对每一种因子计算任务进行详细介绍。

因子任务可以通过以下三种方式执行:

  • (1) 通过交互的方式执行。
  • (2) 通过 submitJob 提交一个Job来执行。
  • (3) 通过 scheduleJob 提交一个定时任务来进行周期性的执行。

7.6.1 全量计算

因子的全量跑批任务,通常是系统初始化因子数据时的一次性任务,或者较长周期进行一次的任务,这类任务可以通过单次触发或者定时任务(scheduleJob)的方式进行管理。

  • 单次触发的任务:这种任务可以通过 gui 直接执行,也可以通过 api 来调用命令,最好的方式是通过 submitJob 函数提交任务。通过 submitJob 提交的任务,会提交到服务器的Job 队列中执行,不再受客户端影响,并且可以通过 getRecentJobs 观察到任务是否完成。

    //对于跑批的任务封装函数 def bacthExeCute(){} // 通过summitjob进行提交 submitJob("batchTask","batchTask", bacthExeCute)

  • 周期性任务:如果计算的因子频率较低需要每天盘后或者其他周期定期全部重算一次,那我们可以使用定时任务(ScheduleJob)的方式进行管理。

    //设置一段时间每天执行 scheduleJob(jobId=`daily, jobDesc="Daily Job 1", jobFunc=bacthExeCute, scheduleTime=17:23m, startDate=2018.01.01, endDate=2018.12.31, frequency='D')

7.6.2 因子运维管理

在因子研发过程中,当碰到因子算法、参数调整的情况,我们会需要对该因子进行重新计算,同时需要将计算的新的因子数据更新到数据库中,对于因子更新的频率通常我们有两种方式:

  • 因子的数据频率较高,数据量很大

    因子的数据频率较高,数据量很大时,我们推荐在因子数据分区时拉长时间维度,以因子名进行VALUE分区。这样可以使每个因子的数据独立的保存在一个分区中,控制分区大小在一个合适的范围。当我们碰到因子重算的情况,便可以用 dropPartition 函数先删除这个因子所对应的分区数据,然后直接重算这个因子并保存到数据表中。

  • 因子的数据频率较低,因子的总数据量较小

    当因子的数据频率较低,因子的总数据量较小时,如若将每个因子划分为独立的分区会使得每个分区特别小,而过小的分区可能会影响写入速度。这种情况下,我们可以按照因子 HASH 分区。使用 update! 来进行因子数据更新操作,或使用 upsert 来进行插入更新操作。此外,对于 TSDB 引擎,可以设置参数 keepDuplicates=LAST , 此时可以直接使用 append! 或者 tableInsert 插入数据,从而达到效率更高的更新数据的效果。

update! , upsert 以及 TSDB 引擎特殊设置下的直接 append! 覆盖数据,这三种更新操作都建议在数据量较小,且更新不频繁的情况下使用。对于需要大量因子重算的数据更新的场景,我们推荐使用单因子独立分区的方式。当因子重算时先用dropPartition函数删除因子所在分区,再重算写入新因子入库。

8. 实际案例

8.1 日频因子

日频的数据,一般是由逐笔数据或者其他高频数据聚合而成。日频的数据量不大,在日频数据上经常会计算一些动量因子,或者一些复杂的需要观察长期数据的因子。因此在分区考虑上,建议按年分区即可。在因子计算上,日频因子通常会涉及时间和股票多个维度,因此建议用面板模式计算。当然也可以根据不同存储模式,选择不同的计算模式。

章节附件8.1 日频因子全流程代码汇总中,模拟了 10 年 4000 只股票的数据,总数据量压缩前大约为 1 GB。代码中会展现上述教程中所涉及日频因子的最佳实践,因子包括 Alpha 1、Alpha 98 ,以及不同计算方式(面板或者SQL模式)写入单值模型、多值模型的最佳实践。

8.2 分钟频因子

分钟频的数据,一般是从逐笔数据或快照数据合成而来。分钟频的数据相比日频的数据较大,在分区设计上建议按月VALUE分区,股票HASH的组合分区。在分钟频的数据上,一般会计算日内的收益率等因子。对于这类因子,建议使用SQL的方式以字段作为参数。很多时候,会将投研的因子,在每日收盘之后,增量做所有因子的计算,此时,也需要对于每日增量的因子做工程化管理。建议将所有此类因子用维度表做一个维护,用定时作业将这些因子批量做计算。

章节附件8.2 分钟频因子全流程代码汇总中,模拟了一年4000只股票的数据,总数据量压缩前大约20GB。其中,会展现上述教程中所有涉及分钟频率的因子的最佳实践,因子包括日内收益偏度因子,factorDoubleEMA等因子,,后续将因子写入单值模型、多值模型的全过程,以及每日增量计算所有因子的工程化最佳实践。

8.3 快照因子

快照数据,一般指3s一条的多档数据。在实际生产中,往往会根据这样的数据产生实时的因子,或根据多档报价、成交量计算,或根据重要字段做计算。这一类因子,推荐使用字段名作为自定义函数的参数。除此之外,由于快照数据的多档的特殊性,普通存储会占用很大的空间,故在存储模式上,我们也推荐将多档数据存为ArrayVector的形式。如此一来,既能节省磁盘空间,又能使代码简洁,省去选取多个重复字段的困扰。

章节附件8.3 快照因子全流程代码汇总中,模拟数据生成了20天快照数据,并将其存储为了普通快照数据和ArrayVector快照数据两种。代码中也展示了对于有状态因子flow和无状态因子权重偏度的在流批一体中的最佳实践。

8.4 逐笔因子

逐笔成交数据,是交易所提供的最详细的每一笔撮合成交数据。每3秒发布一次,每次提供这3秒内的所有撮合记录。涉及逐笔成交数据的因子都是高频因子,推荐调试建模阶段可以在小数据量上使用批处理计算。一旦模型定型,就可以用批处理中同样的计算代码,迁移到流计算中实时处理(这就是所谓的批流一体),比批处理方式节省内存,同时实时性也更高,模型迭代也更快。

章节附件8.4 逐笔因子全流程代码汇总中,会展现上述教程中所有涉及逐笔成交数据的因子计算、流计算。

9. 总结

用DolphinDB来进行因子的计算时,可选择面板和SQL两种方式来封装因子的核心逻辑。面板方式使用矩阵来计算因子,实现思路非常简练;而SQL方式要求投研人员使用向量化的思路进行因子开发。无论哪种方式,DolphinDB均支持批流一体的实现。DolphinDB内置了相关性和回归分析等计算工具,可分析因子的有效性,可对多因子建模。

在因子库的规划上,如果追求灵活性,建议采用单值纵表模型。如果追求效率和性能,推荐使用TSDB引擎,启用多值宽表模式,标的(股票代码)作为表的列。

最后,基于大部分团队的IT和投研相对独立的事实,给出了在代码管理上的工程化方案,投研团队通过模块和自定义函数封装核心因子业务逻辑,IT团队则维护框架代码。同时利用权限模块有效隔离各团队之间的数据访问权限。

附录

章节附件2.1 逐笔数据建库建表

章节附件2.2 快照数据建库建表

章节附件2.3 k线数据建库建表

章节附件4.1.2 流计算大小单因子

章节附件4.1.3 Alpha #1流式计算

章节附件4.2 流计算doubleEma因子

章节附件4.3.1 python接口订阅流数据

章节附件4.3.2 通过ZMQ消息队列收取DolphinDB推送来的流数据

章节附件4.3.3 流计算因子结果推送到外部ZMQ消息队列

章节附件5.1 因子存储模拟测试:

章节附件5.2, 5.3 因子查询测试脚本 :

章节附件6.1 因子向量化回测

章节附件7.2 单元测试

章节附件7.5.3 因子表权限控制

章节附件8.1 日频因子全流程代码汇总

章节附件8.2 分钟频因子全流程代码汇总

章节附件8.3 快照因子全流程代码汇总

章节附件8.4 逐笔因子全流程代码汇总

所有代码附件目录

猜你喜欢

转载自juejin.im/post/7101127862287073316