金融数据源 2026-04

稳定的股票数据源

稳定、优雅、便于运维和使用的股票数据源设计

稳定的股票数据源

本项目为个人项目,为个人量化投资分析提供稳定、优雅、便于运维和使用的股票数据源
项目链接:https://github.com/QiYueran-source/market_data.git

1.项目介绍

在投资分析中,稳定的数据来源至关重要!
在制定策略和回测时,只有数据稳定,字段明确,才能保证策略的稳定性和可靠性。 然而,市场上的常见数据源,如TuShare、AkShare等,虽然丰富,但是均有重要的问题, 那就是:

如果我想用TuShare一个源解决所有问题,每年可能需要支付数千元乃至更多的费用, 这对只想研究,还未实盘执行任何策略的投资者来说,可能是一个沉重的负担。

因此,我想到一个方法: 每个数据源,都会提供一些“薅羊毛”的次数,比如TuShare, 其股票日度数源每天可以免费查询6000次,足够覆盖所有日度数据需求, 但是,该免费源仅支持股票日线数据,对于分时、财务、ETF数据,需要额外付费,并且价格不菲。
麦蕊智数则提供实时数据源,一年花费50元左右,可获得每天10000次查询机会, 且包含了实时,ETF,财务等数据,但是该源的字段命名方式较为混乱, 且覆盖的数据类型不多(如宏观、情绪等,均未提供),未来扩展性较差。

为了“花小钱,办大事”,我使用了多个数据源,每个数据源用来获取不同类型的数据, 这样,可以薅到最多的羊毛。

然而,多数据源对接,是一个复杂问题,不同数据源的接口、字段、限流、异常均有不同, 如何设计一个稳定、优雅、便于运维和使用的股票数据源,是一个值得思考的问题。

2.项目设计

2.1 单一事实设计

由于不同源的字段不同,为了保证数据的一致性, 可以事先约定好字段的名称和含义,如open: REAL 开盘价(元)。 不同数据源的开盘价字段可能不同,如open, o, open_price, kpj等, 需要将不同数据源的开盘价字段映射到统一的名称和含义,并储存到数据库中, 最终需要获取数据,只通过数据库查询,而不需要关心具体的数据源。

为了保证数据的一致性,设计静态类TableSchema,用于存储字段的名称和含义,

from models.table_schema import TableSchema, col
import numpy as np

TableSchema(
    database_name='trade_calendar.db',
    table_name='akshare_trade_calendar',
    schema=[
        col('calendar_date', np.str_, 'TEXT', True),
        col('is_open', np.int8, 'INTEGER', False),
    ],
)

该类是数据库中表在python中的映射,保存了库名、表名、字段名、字段在py中类型、字段在db中类型、字段是主键等信息。

对于每一张表,预先定义好其TableSchema; 创建库时,读取所有的Schema,按照其schema属性生成DDL语句,创建表; 在获取数据源数据后,需要按照对应的TableSchema,将字段的名称和类型统一, 提供一个validate(df, schema)函数,用于校验df是否符合Schema预期。

当然,一个表可以对应多个数据源;一个数据源可以为多张表提供数据。 因此,校验数据时,df并不需要有schema的所有字段, df仅需要有schema中定义的主键,且不能有schema中未定义的字段即可, 在更新时,使用upsert,按主键将df更新到数据库中。

2.2 读写分离设计

读写分离设计,主要是减少直接定义DML语句,提高扩展性和可维护性。

为了实现读写分离,设置一个Buffer类,用于缓存数据, 当缓存收集到一定数量后,更新到数据库中。

Buffer(
    buffer_size = 1000,
    schema = XXXTableSchema,
)

一个Buffer类与一个表绑定,并且提供append(df)flush()函数, 使用append函数,将df添加到buffer,buffer_size增加len(df)条, 在达到buffer_size时,调用flush函数,将buffer中的数据更新到数据库中。

为了保证数据一致性,append后,仍然调用validate(df, schema)函数,校验df是否符合Schema预期。 如果校验通过,则将df添加到buffer,否则,不添加到buffer,并返回错误信息。

2.3 Provider容错设计

Provider层负责对接数据源,由于数据源的异常情况各有不同, 需要进行异常管理。

具体来说,每一个数据源,对应一个provide文件, 提供fetch()函数,用于对接数据源,获取数据。
fetch()函数中,捕获所有的异常,并且抛出;
然后,定义fetch_and_clean()函数,用于捕获所有fetch()中捕获的异常, 并且提供兜底。 这样,将所有异常在fetch_and_clean()函数中处理。

具体来说,fetch_and_clean()函数中,需要进行以下操作: - 1.捕获异常
- 2.使用logger.exception记录异常
- 3.返回兜底数据

定义一个字典Counter(),用来记录兜底数据的使用次数, 返回给上层,用于邮件汇报。

这样,可以避免将API错误暴露,而是在日志中记录,便于后续排查。

2.4 限流器设计

限流器是数据源的重要限制,每一个数据源都有限制规则, 如麦蕊智数的限流为每分钟300次。

可以设定限流器类,为每一个数据源设置限流:

limiter = APILimiter(
    interval = 60, # 间隔
    max_requests = 300, # 最大请求数
)   

内部,使用滚动窗口的方式: 每一次请求后,都将请求加入进行队列,并且请求完成后,统一停止interval秒, 在请求队列满后,其他请求被阻塞,直到队列中的请求完成。

根据不同的数据源,初始化不同的限流器常量,并且提供一个装饰器,用于装饰fetch函数:

@limiter('mairui')
def fetch():
    pass 

该装饰器内部,每次调用func后,都调用limiter.acquire(), 如果返回True,则继续执行func,否则,阻塞等待,直到队列中的请求完成。

装饰器可以传入数据源名称参数,用于选择不同的限流器。

2.5 Job层

Job层负责调度Provider和Buffer层,实现数据更新, 同时提供一个JobInfo信息,用于记录本次Job的执行情况,交给执行层发送邮件。

Job层还负责异常最终处理,在Job层,对于已经定义的异常,如数据库写入、缓存异常等问题, 已经有了定义好的异常,则使用logger.exception记录异常; 对于为定义异常,则使用try ... except Excpetion as e的方式捕获,并传给JobInfo,用于邮件汇报。

2.6 执行层

执行层是程序的入口,负责启动Job层。 根据业务需求不同,设定了若干个执行脚本,使用crontab定时执行。 在执行完后,根据收集到的JobInfo发送信息到邮箱。

最终的邮件效果如下:

邮件效果