中高频多因子库存储最佳实践

1. 概述

因子挖掘是量化交易的基础。随着量化交易竞争的加剧,量化投资团队需要处理大量因子。在许多情况下,因子数据量甚至会远远超过高频的行情数据量。以5,000只股票10,000个因子为例,一年的10分钟线数据量为 2.3TB,1分钟线数据量为 23TB,3秒线数据量为 460TB。如此量级的数据就对因子存储方案提出了很高的要求。

本文将基于中高频多因子存储场景,结合实际数据案例来设计 DolphinDB 存储方案,并对比不同存储模式下的性能,给出最佳存储模式的建议。

1.1. 中高频多因子存储面临的挑战

在数据高频次和因子高数量的双重叠加之下,数据量将轻易达到 TB 级别,那么中高频多因子的存储方案就必须同时面对以下问题:

  • 庞大的数据量

因子计算通常有4个维度包括股票、因子、频率和时间。国内股票总个数按5,000来算。因子个数一般机构大约为1,000起,多的甚至有10,000个因子。时间频率最高的是每3秒钟生成一次数据,频率低的也有10分钟一次,也就是说,一只股票一个因子一天会生成24到4,800条 tick 数据。

宽表存储模式数据量统计:

股票(只)因子(个)频率时间数据量(GB)
5,00010,00010 分钟线4 年9,014
5,0001,0001 分钟线2 年4,513
5,0001,0003 秒线1 年45,129

窄表存储模式数据量统计:

股票(只)因子(个)频率时间数据量(GB)
5,00010,00010 分钟线4 年27,037
5,0001,0001 分钟线2 年13,518
5,0001,0003 秒线1 年134,183

面对如此庞大的数据量,如何保证高效的数据写入和数据压缩是因子库存储的一大挑战,如果不能支持并充分发挥多块磁盘的 I/O,写入耗时将达数小时以上。

  • 因子库动态变化

因子库经常会发生变化,往往需要新增因子、修改因子定义,或加入新的股票等。面对 TB 级的因子数据,单个因子的新增、修改、删除耗时应该保证在秒级才能确保量化投研的效率。

对于上述问题,我们在设计方案时,除了尽可能优化每一项操作的性能,更重要的是每项性能不能出现明显短板,否则在数据操作量级大幅上升后,会大幅度降低整体的生产效率。

  • 多因子对齐输出

金融行业的数据分析通常需要数据支持面板格式。对于读取随机标的(A 股市场目前约5,000只股票)、随机多个因子(10,000个因子中随机查询1,000个因子)的场景,需要在操作时能够尽可能高速并精准读取数据,减少无效 I/O ,并以需要的方式(通常是因子面板模式)将数据读取出来。

1.2. 中高频多因子存储场景需求

总体来看,中高频多因子的存储方案需要满足以下几点:

  • 保证写入高效性;
  • 支持高效的因子库运维(新增因子及因子数据的修改、删除);
  • 支持高效、灵活的读取方式,且以最适合金融计算的方式输出。

2. DolphinDB 的存储特性

面对中高频多因子存储场景,我们将 DolphinDB 的下述存储特性组合使用,提供了灵活的存储解决方案。

2.1. 分区存储

数据分区存储,针对因子数据可以采用“时间+因子/标的”的方式组合分区来达成因子数据的灵活分区存储。DolphinDB 对于不同分区数据可以多线程并行操作。

2.2. 分区内分组排序存储

DolphinDB 的 TSDB 引擎提供排序键设置,对分区内的数据完成分块索引。这样的设置有助于在随机读时更精准的定位数据。例如,在“2022年2月-factor1”的分区内部,数据可以按照 SortColumn-SortKey 字段 SecurityID 分组存储,在每个 SecurityID 组内,再按照时间字段 TradeTime 排序,如下图所示。


借助以上特性,可以灵活地设计存储方案,以应对中高频多因子场景下的不同需求。

下文中,我们以一个“10分钟级100000因子”的例子,为大家测试在不同存储模式下,数据的写入、查询和运维等方面的性能,并通过分析结果,为大家提供一个中高频多因子库存储的最佳实践。

3. 十分钟级一万因子存储场景解决方案

3.1. 存储方案设计

在存储因子数据时,用户可以选择窄表和宽表两种模式。

窄表模式一般有4列:时间戳、股票代码、因子编号以及因子值,如下图所示。在需要面板数据的场景中,窄表模式的数据可使用 DolphinDB 的pivot功能转换为面板数据。面对金融场景时序数据中大量因子需要对齐转置的情形,可以根据时间、股票代码和因子对数据表重新排序,将时间和股票代码作为行,因子作为列进行计算输出,并且非常高效。


宽表模式中,一般每个因子存为一列,如下图所示。宽表模式下的面板数据可以直接用于量化交易中的程序计算,符合金融场景的数据输出需求,但在测试案例一三种运维操作的测试数据对比中,我们会看到,因子数据的新增和修改场景下宽表耗时较高。


DolphinDB 中同时支持宽表和窄表的两种模式数据存储。结合 DolphinDB 的存储特性,我们设计以下两种存储方案来比对10分钟级10,000因子场景存储性能:

方案 1:窄表模式

  • TradeTime 按月值分区 + FactorName 值分区
  • 排序字段: SecurityID + TradeTime

把时间分区调整到月,对因子分区调整到每个因子单独分区,并对每个分区内的数据按照 SecurityID 分组,组内按照 TradeTime 排序。这样的好处是每个分区数据大小适合,在数据检索时,既可以按照时间和因子名进行分区剪枝干,又可以按照股票 ID 近一步的精确定位数据,满足在随意组合因子、标的场景下精准地读取数据。

方案 2:宽表模式

  • TradeTime 按月值分区 + SecurityID 值分区
  • 排序字段: SecurityID + TradeTime

在 SecurityID 上进行分区剪枝,因子维度上通过选择不同的列来进行数据筛选。

下文将在固态硬盘(SSD)和机械硬盘(HDD)这两种不同的硬件配置下,对宽表和窄表的存储性能分别进行测试。

3.2. 数据准备

我们通过模拟随机生成5,000只股票10分钟级10,000个因子的数据,并分别采用窄表和宽表两种方式来存储。

随机生成因子名称和股票代码的函数定义如下:

def createFactorNamesAndSymbolNames(num_factors,num_symbols){
	factor_names = lpad(string(1..num_factors),6,"f00000")
	symbols_preliminary = lpad(string(1..num_symbols),6,"000000")+"."
	areas = rand(["SZ","SH"],num_symbols)
	symbols = symbols_preliminary + areas
	return factor_names,symbols
}

生成字段及字段类型的函数定义如下:

def createColnameAndColtype(mode,factor_names){
	if(mode == "single"){
		return ["tradetime","symbol","factorname","value"],[DATETIME,SYMBOL,SYMBOL,DOUBLE]
	}else{
		col_names = ["tradetime","symbol"].append!(factor_names)
		col_types = [DATETIME,SYMBOL].append!(take(DOUBLE,factor_names.size()))
		return col_names,col_types
	}
}

3.3. 测试案例一:HDD 存储

服务器配置:

  • CPU:64核
  • 内存:512G
  • 磁盘:9块 HDD 硬盘
  • 数据库设置:单机集群三数据节点

因子写入

对于5,000只股票10,000个因子,我们首先测试写入2022.1.1至2022.1.31内的10 钟级数据,可以运行如下代码定义不同存储模式下的因子写入函数:

// 窄表模式写入某个时间范围数据
def writeSingleModelData(dbname,tbname,start_date,end_date,symbols,factor_names){
	total_time_range = getTimeList(start_date,end_date)
	nodes = exec value from pnodeRun(getNodeAlias)
	for(j in 0..(total_time_range.size()-1)){
		for(i in 0..(factor_names.size()-1)){
			rpc(nodes[i%(nodes.size())],submitJob,"singleModel"+j+"and"+i,dbname,singleModelPartitionData,dbname,tbname,total_time_range[j],symbols,factor_names,factor_names[i])
		}
	}
}

// 宽表模式写入某个时间范围数据
def writeWideModelData(dbname,tbname,start_date,end_date,symbols,factor_names){
	total_time_range = getTimeList(start_date,end_date)
	nodes = exec value from pnodeRun(getNodeAlias)
	for(j in 0..(total_time_range.size()-1)){
		for(i in 0..(symbols.size()-1)){
			rpc(nodes[i%(nodes.size())],submitJob,"wideModel"+j+"and"+i,dbname,wideModelPartitionData,dbname,tbname,total_time_range[j],factor_names,symbols[i])
		}
	}
}

可以看到,宽表模式在数据写入速度上优于窄表模式,硬盘占用空间上略优于窄表模式。这是因为窄表模式下的数据冗余度高,实际数据量比较大。另外需要说明一点,实验中因子值使用的是随机的浮点数,几乎没有重复,压缩比较低,实际场景中的压缩比会更高。

存储方案写入天数每天行数总行数每行字节数据原始大小 (GB)落盘大小 (GB)写入耗时 (s)压缩比磁盘 I/O(MB/s)
窄表211,200,000,00025,200,000,000244781855232.6937
宽表21120,0002,520,00080,0121901663011.1648

因子查询

查询21天全市场5,000只标的的1,000个因子数据,窄表的查询会将数据转换成与宽表一样的面板数据输出。定义因子查询函数的核心代码如下:

// 窄表模式查询随机1000因子
def querySingleModel(dbname,tbname,start_time,end_time,aim_factor){
	return select value from loadTable(dbname,tbname) where tradetime>=start_time and tradetime<= end_time and  factorname in aim_factor pivot by tradetime,symbol,factorname
}

// 宽表模式查询随机1000因子
def queryWideModel(dbname,tbname,start_time,end_time,aim_factor){
	ll = aim_factor[0]
	for(i in 1..(aim_factor.size()-1)){
		ll = ll+","+aim_factor[i]
	}
	script = "select tradetime,symbol,"+ll+"from loadTable("+'"'+dbname+'"'+","+'"'+tbname+'"'+")" + "where tradetime>="+start_time+"and tradetime<="+end_time
	tt = parseExpr(script).eval()
	return tt
}

数据以宽表模式存储在机械硬盘中时,对10,000个因子随机查询1,000个因子的初次查询速度慢一些;查询前1,000个因子则速度较快。这是因为在机械硬盘下进行多列的随机检索会比较慢。

即便如此,与宽表模式的最快情况相比,窄表模式下经过pivot by转为面板数据后的查询速度也要更快。这是因为虽然宽表的所有列已经按照面板模式准备好,但是宽表的数据都是在同一个分区,读取时是单线程进行。而窄表模式数据存在于很多因子分区 ,读取数据和拼接面板数据时是很多个 CPU 同时工作,这是个分布式多线程操作,所以窄表模式在查询面板数据时耗时更少。

存储方案因子选择数据大小 (GB)冷查询耗时 (s)热查询耗时 (s)
窄表随机 1,00018.88052
宽表随机 1,00018.842549
宽表前 1,000 个18.89249

数据运维

因子数据的运维包括新增因子、更新因子、删除因子。

  • 新增因子

在新增因子的场景,窄表模式可以使用append!插入新的因子数据;而宽表模式需要先进行addColumn操作,然后通过update操作更新新增因子列数据。在 DolphinDB 当前的设计下,更新宽表模式中某一列因子,需要将分区数据全部重写,耗时较长

假设此处需要新增第 f10002 号因子在2022.1.1至2022.1.31时间范围内的数据,不同存储模式下的新增因子脚本如下所示:

//窄表模式新增1个因子
def singleModelAddNewFactor(dbname,tbname,start_date,end_date,symbols,factor_names,new_factor){
	time_list = getTimeList(start_date,end_date).flatten()
	num_row = symbols.size()*time_list.size()
	col_names,col_types = createColnameAndColtype("single",factor_names)
	t = table(num_row:num_row,col_names,col_types)
	t["tradetime"] = stretch(time_list,num_row)
	t["symbol"] = take(symbols,num_row)
	t["factorname"] = take(new_factor,num_row)
	t["value"] = rand(100.0,num_row)
	pt = loadTable(dbname,tbname)
	pt.append!(t)	
}

//宽表模型新增一个因子
def wideModelAddNewFactor(dbname,tbname,start_date,end_date,symbols,new_factor,parallel = true){   //parallel=true表示并行,=false表示串行
	pt = loadTable(dbname,tbname)
	addColumn(pt,[new_factor],[DOUBLE])
	time_list = getTimeList(start_date,end_date)
	start_time_list,end_time_list = [],[] 
	for(i in 0..(time_list.size()-1)){
		start_time_list.append!(time_list[i][0])
		idx = time_list[i].size()-1
		end_time_list.append!(time_list[i][idx])
	}
	if(!parallel){
		for(i in 0..(start_time_list.size()-1)){
			for(j in 0..(symbols.size()-1)){
				wideModelSinglePartitionUpdate(dbname,tbname,start_time_list[i],end_time_list[i],new_factor,symbols[j])
			}
		}
	}else{
		for(i in 0..(start_time_list.size()-1)){
			ploop(wideModelSinglePartitionUpdate{dbname,tbname,start_time_list[i],end_time_list[i],new_factor,},symbols)
		}
	}
}
  • 更新因子

量化投研中,重新计算因子数据是常见的场景。根据窄表模式下的分区规则,对指定因子数据更新时,可以精确定位到因子所在分区,并进行修改,所以耗时在秒级;而宽表模式的更新方式如上节所述原因,耗时较长。

假定此处需要更新第 f00555 号因子在2022.1.1至2022.1.31时间范围内的因子值数据,不同存储模式下的脚本如下所示:

//窄表模式更新1个因子
def singleModelUpdateFactor(dbname,tbname,start_date,end_date,update_factor,parallel = false){   //parallel=true表示并行更新
	time_list = getTimeList(start_date,end_date)
	start_time_list,end_time_list = [],[] 
	for(i in 0..(time_list.size()-1)){
		start_time_list.append!(time_list[i][0])
		idx = time_list[i].size()-1
		end_time_list.append!(time_list[i][idx])
	}
	if(!parallel){
		for(i in 0..(start_time_list.size()-1)){
			singleModelSinglePartitionUpdate(dbname,tbname,start_time_list[i],end_time_list[i],update_factor)
		}		
	}else{
		ploop(singleModelSinglePartitionUpdate{dbname,tbname,,,update_factor},start_time_list,end_time_list)
	}
}

//宽表模型更新1个因子
def wideModelUpdateFactor(dbname,tbname,start_date,end_date,update_factor,symbols,parallel = true){  //parallel=true表示并行更新,=false表示串行
	time_list = getTimeList(start_date,end_date)
	start_time_list,end_time_list = [],[] 
	for(i in 0..(time_list.size()-1)){
		start_time_list.append!(time_list[i][0])
		idx = time_list[i].size()-1
		end_time_list.append!(time_list[i][idx])
	}
	if(!parallel){
		for(i in 0..(start_time_list.size()-1)){
			for(j in 0..(symbols.size()-1)){
				wideModelSinglePartitionUpdate(dbname,tbname,start_time_list[i],end_time_list[i],update_factor,symbols[j])	
			}
		}
	}else{
		for(i in 0..(start_time_list.size()-1)){
			ploop(wideModelSinglePartitionUpdate{dbname,tbname,start_time_list[i],end_time_list[i],update_factor,},symbols)
		}
	}
}
  • 删除因子

删除因子虽然不是必须的,但可以释放存储空间,以及提供其他便利。当前窄表模型的分区方案在删除指定因子时耗时在秒级,脚本如下所示,TSDB 引擎下的宽表模式目前不支持删除因子列。

// 单值模型删除一个因子
def singleModelDeleteFactor(dbname,tbname,start_date,end_date,delete_factor){
	pt = loadTable(dbname,tbname)
	time_list = getTimeList(start_date,end_date).flatten()
	start_time,end_time = time_list[0],time_list[time_list.size()-1]
	delete  from pt where tradetime >= start_time and tradetime <= end_time and factorname = delete_factor
}

三种运维操作下的测试数据如下表所示,可以看到在10分钟级10,000个因子数据场景下,窄表模式在因子数据查询和因子数据运维方面全面优于宽表模式,只是在数据写入速度和存储空间要逊于宽表模式。综合考虑各个方面,使用窄表模式存储因子数据是更好的选择。

数据运维操作窄表 (s)宽表 (s)
新增 1 因子1.2534
更新 1 因子1.1541
删除 1 因子0.8N/A

3.4. 测试案例二:SSD 存储

上一节中,我们使用机械硬盘比对了一个月因子数据场景下宽表和窄表的性能。在实际生产时,为了提高效率,我们往往选择 SSD 硬盘来存储因子数据。本小节我们就选择 SSD 硬盘来进行测试,进行6个月因子数据场景下宽表和窄表的性能比对。同样从因子写入、因子查询和数据运维三个方面进行测试。

服务器配置:

  • CPU 48 核
  • 内存:512G
  • 磁盘:4 块 SSD 硬盘
  • 数据库设置:单机集群二数据节点

因子写入

通过多任务并行方式写入6个月5,000只标的10分钟级10,000 子数据,定义因子写入函数的代码与案例一中因子写入一致,完整脚本可参考附件。从结果我们可以看到,宽表模式在写入速度和存储空间上性能占优。

存储方案写入天数每天行数总行数每行字节数据原始大小 (GB)落盘大小 (GB)写入耗时 (s)压缩比磁盘 I/O(MB/s)
方案 1 - 窄表1291,200,000,000154,800,000,000242,8731,1182,1942.61,338
方案 2 - 宽表129120,00015,480,00080,0121,1431,0301,1151.11,049

因子查询

接下来我们随机查询5,000只标的下1,000个因子在1, 3, 6个月内的数据,核心查询代码与案例一中一致,完整脚本可参考附件。

测试结果如下所示,其中冷查询表示在无数据缓存的情况下进行查询,热查询表示有数据缓存的情况下进行查询 。可以看到,在使用 SSD 磁盘的情况下,窄表模式的查询耗时同样低于宽表模式。此外随着查询数据量增长,查询耗时是线性增长的,不会因为查询数据量的大增而出现查询耗时大幅增加的情况。

存储方案查询数据大小 (GB)查询数据月份冷查询耗时 (s)热查询耗时 (s)
窄表18.813734
宽表18.815943
窄表57.3310390
宽表57.33171115
窄表115.56201173
宽表115.56363274

数据运维

同样,我们从新增、更新和删除因子三个角度,测试1, 3, 6个月的数据运维性能,核心代码与案例一中一致,完整脚本可参考附件。在这个环节窄表模式同样远远优于宽表模式,且数据运维的各项操作,耗时同样随操作影响数据量线性增长。

数据运维操作操作因子数据月份窄表 (s)宽表 (s)
新增 1 因子11.0185
新增 1 因子32.9502
新增 1 因子65.81,016
更新 1 因子11.1162
更新 1 因子33.1477
更新 1 因子66.2948
删除 1 因子10.8N/A
删除 1 因子31.1N/A
删除 1 因子61.2N/A

吞吐量

在案例一中我们提到,窄表模式下查询面板数据时,任务会以分布式多线程的方式处理,这将消耗较多的 CPU 资源。

为了验证在多线程并发查询因子数据时,查询性能是否会因为 CPU 资源竞争大幅下降,我们进行了并发查询测试。

我们采取并发8个线程查询一个月全市场5,000只股票、随机10,000个因子数据,每个查询数据大小为 19GB,测试结果如下表。

存储方案总耗时(s)
窄表247
宽表242

通过比对我们可以看到,在8个并发查询的场景,窄表模式的查询确实有一定下降,但总耗时仍然可以达到和宽表模式基本相同的水准。

本节在 SSD 硬盘上测试了6个月的因子数据场景。使用合理设计的窄表模式存储 TB 级别的因子数据,在数据写入、多因子随机查询、因子数据运维等各方面均有稳定表现。在8 程并发情况下查询速度与宽表模式相当。此外,查询、因子运维耗时能够保证线性增长,不会因为海量数据不断增加而出现查询、因子运维耗时大幅增加情况,这样的特性是数据库可以支持海量数据的重要保证。

3.5. 中高频多因子库存储的最佳实践:SSD vs HDD

前两节中我们分别在 HDD 和 SSD 两种硬盘环境下进行了宽表和窄表的存储性能比对。但是在其他系统资源一样的情况下,HDD 和 SSD 的差别是否很大,也是我们比较关心的一件事情。本小节我们将在下表所示的两种硬件配置环境下,对比测试不同存储模式的性能,为大家提供中高频多因子库存储的最佳实践建议。

服务器配置项SSDHDD
CPU64 核64 核
内存512G512G
磁盘3 块 SSD 硬盘(1.5GB/s 吞吐量)9 块 HDD 硬盘(1.4GB/s 吞吐量)
数据库设置单机集群三数据节点单机集群三数据节点

因子写入

写入一个月5,000只标的10钟级10,000因子数据,定义因子写入函数的代码与案例一中一致,完整脚本可参考附件。在磁盘总 I/O 相近的情况,SSD 硬盘的写入速度略优于 HDD 硬盘。

存储方案存储配置数据原始大小 (GB)落盘大小 (GB)压缩比磁盘 IO(MB/s)写入耗时 (s)
SSD窄表4791862.61,068459
SSD宽表1911661.1757257
HDD窄表4791862.6937523
HDD宽表1911661.1648301

因子查询

分别进行查询一个月全市场5,000只股票 随机1,000个因子数据,查询数据量大小 19G,核心查询代码与案例一中一致,完整脚本可参考附件。从下表中可以看到,使用 HDD 硬盘的冷查询和热查询会相差大一些。而无论 SSD 硬盘还是 HDD 硬盘,窄表模式的分布式多线程查询都可以保持更加稳定的性能。

存储方案存储配置冷查询耗时 (s)热查询耗时 (s)
SSD窄表6461
SSD宽表16255
HDD窄表8052
HDD宽表42549

数据运维

在运维场景下,窄表模式的落盘后数据量为 186GB ,宽表为 166GB,新增、更新和删除因子的核心代码与案例一中一致,完整脚本可参考附件。测试结果如下所示,可以看到无论是 SSD 还是 HHD ,窄表模式的运维操作耗时都非常低,基本都是秒级。而宽表模式虽然在 SDD 硬盘下耗时要少于 HDD 硬盘,但总体耗时仍然非常高。

存储方案数据运维操作窄表 (s)宽表 (s)
SSD新增 1 因子1.1330
SSD更新 1 因子0.9316
SSD删除 1 因子0.5N/A
HDD新增 1 因子1.2534
HDD更新 1 因子1.1541
HDD删除 1 因子0.8N/A

从上述结果我们可以看到。在同等条件下,选择 SSD 磁盘在写入场景、冷查询场景性能都要好于 HDD 磁盘。而在因子运维场景,窄表模式耗时比较短,相差并不明显。宽表模式则有较大差异。也就是说在因子数据使用的各个场景采用 SSD 磁盘都可以获得更好的性能。故在因子数据存储上,我们推荐使用 SSD 磁盘来进行因子数据存储。

本教程通过一个“10分钟级10,000因子数据”的例子,测试了因子在不同存储模式(宽表、窄表)和硬盘选择(HDD, SSD)下的写入、新增、更新、删除,以及多线程并发查询时的性能。

通过测试结果可知:在10分钟级10,000因子场景下,采取按月 Value 分区 + 因子名 Value 分区,以及 SortColumn 为 SecurityID+TradeTime 设置下的窄表模式对数据进行存储,为最佳解决方案。

虽然在并发查询场景下,窄表模式的查询操作会因为分布式处理导致的 CPU 资源竞争而增加耗时,但仍可以保证与宽表模式的查询耗时差距不超过5%。

同时,该存储方案无论在 HDD 硬盘或 SSD 硬盘中,都能保持稳定的性能,查询场景性能优于宽表模式,因子运维场景下优势更加明显,各项指标均达到秒级,仅在数据写入环节的性能略逊于宽表模式。

因此,在中高频多因子库的存储方案选择中,我们更推荐用户采用合理设计存储模型的窄表模式。