火力全开,优化 python 量化日行情数据下载以及常用指标计算速度

火力全开,优化 python 量化日行情数据下载以及常用指标计算速度

今天做了个异步协程的测试,结果有点失望,多进程 + 协程并没有提高 io 密集型任务请求的效率。至于为什么,原因暂时没找到,怀疑是协程的事件循环耗了时,或者是任务并没有构造出真正可等待对象。有知道的网络大神能否提下专业意见。

不过重构了日行情下载速度和常用指标计算速度,还是大大减少了时间的花销。日行情数据 5000 个股票,更新时间段是 2015-01-01 到 2022-12-02 ,从下载到完全插入到数据库大概 6 分钟左右。在这个 7 年的时间段内,常用指标计算并保存到数据库,即均线指标以及个股振幅指标 5、10、20、50、120、250,大概花费 2.5分钟左右。

为日后扩展其他指标打下基础,更新一次估计能控制在 10 分钟以内。

测试 io 密集型任务,cup 密集型任务

下面是测试代码和结果,从结果上看,io 密集型任务多进程的效率和多进程 + 协程的效率是差不多的。对于 cpu 密集型任务来说,多进程明显提高了效率。

可以尝试换事件循环 uvloop ,使用这个事件循环协程效率可提高,但 window 并不支持,所以这个步骤我没测试到。

测试代码:

import asyncio
import os
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, wait

import akshare as ak
import pandas as pd

import config.stockconstant as sc


async def _task_io2():
    loop = asyncio.get_event_loop()
    future = loop.run_in_executor(None, ak.stock_zh_a_hist, '000001', 'daily', '20150101', '20221202', 'qfq')
    response = await future
    # print(f"请求回来了 {response.empty}")


async def _read_csw():
    return pd.read_csv(sc.config_path)


def _task_io():
    pd.read_csv(sc.config_path)


def _task_cpu():
    return sum(i * i for i in range(10 ** 5))


async def async_thread_pool(is_io, total, cpu_count):
    executor = ThreadPoolExecutor(cpu_count)
    loop = asyncio.get_event_loop()
    t1 = time.time()
    tt = [loop.run_in_executor(executor, _task_io if is_io else _task_cpu) for _ in range(total)]
    await asyncio.wait(tt)
    print(f'async_thread_pool  耗时:{time.time() - t1} 任务量:{total}  {"io 任务" if is_io else "cpu 任务"}')


async def async_process_pool(is_io, total, cpu_count):
    executor = ProcessPoolExecutor(cpu_count)
    loop = asyncio.get_event_loop()
    t1 = time.time()
    tt = [loop.run_in_executor(executor, _task_io if is_io else _task_cpu) for _ in range(total)]
    await asyncio.wait(tt)
    print(f'async_process_pool 耗时:{time.time() - t1} 任务量:{total}  {"io 任务" if is_io else "cpu 任务"}')


def thread_pool(is_io, total, cpu_count):
    executor = ThreadPoolExecutor(cpu_count)
    t1 = time.time()
    futures = [executor.submit(_task_io if is_io else _task_cpu) for _ in range(total)]
    wait(futures)
    print(f'thread_pool        耗时:{time.time() - t1} 任务量:{total}  {"io 任务" if is_io else "cpu 任务"}')


def process_pool(is_io, total, cpu_count):
    executor = ProcessPoolExecutor(cpu_count)
    t1 = time.time()
    futures = [executor.submit(_task_io if is_io else _task_cpu) for _ in range(total)]
    wait(futures)
    print(f'process_pool       耗时:{time.time() - t1} 任务量:{total}  {"io 任务" if is_io else "cpu 任务"}')


def main(total, cpu_count):
    print(f'cpu:{cpu_count} 个')
    asyncio.run(async_thread_pool(True, total, cpu_count))
    asyncio.run(async_process_pool(True, total, cpu_count))
    thread_pool(True, total, cpu_count)
    process_pool(True, total, cpu_count)

    print('--------------------------------------')

    asyncio.run(async_thread_pool(False, total, cpu_count))
    asyncio.run(async_process_pool(False, total, cpu_count))
    thread_pool(False, total, cpu_count)
    process_pool(False, total, cpu_count)
    print('=========================================')


def coroutine(total):
    t1 = time.time()
    tasks = [asyncio.ensure_future(_task_io2()) for _ in range(total)]
    loop = asyncio.get_event_loop()
    loop.run_until_complete(asyncio.wait(tasks))
    print(f'coroutine       耗时:{time.time() - t1} 任务量:{total}')


if __name__ == '__main__':
    total = 1000
    cpu_count = 1
    main(total, cpu_count)
    total = 10000
    main(total, cpu_count)

    total = 1000
    cpu_count = int(os.cpu_count() / 2)
    main(total, cpu_count)
    total = 10000
    main(total, cpu_count)

    cpu_count = int(os.cpu_count())
    total = 1000
    main(total, cpu_count)
    total = 10000
    main(total, cpu_count)

    # 修改成异步来执行
    cpu_count = int(os.cpu_count())
    start_time = time.time()
    pool = ProcessPoolExecutor()
    total = 5000
    futures = []
    for part in range(cpu_count):
        future = pool.submit(coroutine, int(total / cpu_count))
        futures.append(future)
    pool.shutdown(wait=True)
    print(f'5k 次股票请求耗时: {time.time() - start_time}')


结果:


cpu:1 个
async_thread_pool  耗时:8.617908000946045 任务量:1000  io 任务
async_process_pool 耗时:10.806799411773682 任务量:1000  io 任务
thread_pool        耗时:8.566416263580322 任务量:1000  io 任务
process_pool       耗时:10.714466333389282 任务量:1000  io 任务
--------------------------------------
async_thread_pool  耗时:16.213727474212646 任务量:1000  cpu 任务
async_process_pool 耗时:18.297339916229248 任务量:1000  cpu 任务
thread_pool        耗时:16.50342082977295 任务量:1000  cpu 任务
process_pool       耗时:18.00312089920044 任务量:1000  cpu 任务
=========================================
cpu:1 个
async_thread_pool  耗时:92.37166738510132 任务量:10000  io 任务
async_process_pool 耗时:101.25876951217651 任务量:10000  io 任务
thread_pool        耗时:86.62425088882446 任务量:10000  io 任务
process_pool       耗时:93.58890128135681 任务量:10000  io 任务
--------------------------------------
async_thread_pool  耗时:156.15055894851685 任务量:10000  cpu 任务
async_process_pool 耗时:157.71604180335999 任务量:10000  cpu 任务
thread_pool        耗时:154.52726674079895 任务量:10000  cpu 任务
process_pool       耗时:155.301522731781 任务量:10000  cpu 任务
=========================================
cpu:4 个
async_thread_pool  耗时:5.767933368682861 任务量:1000  io 任务
async_process_pool 耗时:4.886445999145508 任务量:1000  io 任务
thread_pool        耗时:5.5500757694244385 任务量:1000  io 任务
process_pool       耗时:4.64022421836853 任务量:1000  io 任务
--------------------------------------
async_thread_pool  耗时:15.328773975372314 任务量:1000  cpu 任务
async_process_pool 耗时:6.7509706020355225 任务量:1000  cpu 任务
thread_pool        耗时:15.10576057434082 任务量:1000  cpu 任务
process_pool       耗时:6.597245454788208 任务量:1000  cpu 任务
=========================================
cpu:4 个
async_thread_pool  耗时:56.77140545845032 任务量:10000  io 任务
async_process_pool 耗时:31.66841983795166 任务量:10000  io 任务
thread_pool        耗时:54.32371783256531 任务量:10000  io 任务
process_pool       耗时:30.22367548942566 任务量:10000  io 任务
--------------------------------------
async_thread_pool  耗时:153.32735443115234 任务量:10000  cpu 任务
async_process_pool 耗时:51.863539695739746 任务量:10000  cpu 任务
thread_pool        耗时:150.73832869529724 任务量:10000  cpu 任务
process_pool       耗时:51.08239006996155 任务量:10000  cpu 任务
=========================================
cpu:8 个
async_thread_pool  耗时:5.868973731994629 任务量:1000  io 任务
async_process_pool 耗时:4.749261856079102 任务量:1000  io 任务
thread_pool        耗时:5.750915765762329 任务量:1000  io 任务
process_pool       耗时:4.7755701541900635 任务量:1000  io 任务
--------------------------------------
async_thread_pool  耗时:15.485109329223633 任务量:1000  cpu 任务
async_process_pool 耗时:6.287221908569336 任务量:1000  cpu 任务
thread_pool        耗时:15.208913326263428 任务量:1000  cpu 任务
process_pool       耗时:6.2330849170684814 任务量:1000  cpu 任务
=========================================
cpu:8 个
async_thread_pool  耗时:58.56171226501465 任务量:10000  io 任务
async_process_pool 耗时:25.237155437469482 任务量:10000  io 任务
thread_pool        耗时:55.95446968078613 任务量:10000  io 任务
process_pool       耗时:24.77057647705078 任务量:10000  io 任务
--------------------------------------
async_thread_pool  耗时:153.94631671905518 任务量:10000  cpu 任务
async_process_pool 耗时:40.62843203544617 任务量:10000  cpu 任务
thread_pool        耗时:151.06180787086487 任务量:10000  cpu 任务
process_pool       耗时:38.96478891372681 任务量:10000  cpu 任务
=========================================


5k 次股票请求耗时: 351.0791549682617

Process finished with exit code 0

优化股票日行情数据请求速度

也没啥好说的,就是把日行情请求抽成任务函数,然后扔到进程池里面执行即可,这里要说下进程间内存并不共享,那么如何保存每个请求的结果呢?需要用 multiprocessing.Manager() 来构建对象,即进程可以共享这些对象。

核心代码如下,结果 5k 个股票请求时间 20150101 - 20221202 时间段,全部请求完毕并保存到数据库,大概 6 分钟左右。用多进程效果也是一致的,注释的代码就是使用多进程的。

def _update_daily_task(self, code, start_time,
                       end_time, except_code_list,
                       index, success_code_list):
    try:
        df = ak.stock_zh_a_hist(
            symbol=str(code),
            start_date=start_time,
            end_date=end_time,
            adjust="qfq")
    except:
        except_code_list.append(code)
        print("发生异常code ", code)
        return

    print('成功获取股票: index->{} {}日行情数据'.format(index, code)
          , ' 开始时间: {} 结束时间: {}'.format(start_time, end_time),
          f'数据是否为空:{df.empty}')
    if df.empty:
        success_code_list.append(code)
        return

        # 获取对应的子列集
    sub_df = df[['日期', '开盘', '最高', '最低', '收盘', '成交量', '成交额']]
    # net_df 的列名可能和数据库列名不一样,修改列名对应数据库的列名
    sub_df.columns = ['date', 'open', 'high', 'low', 'close', 'volume', 'amount']
    # 修改 index 为 date 去掉默认的 index 便于直接插入数据库
    sub_df.set_index(['date'], inplace=True)
    sub_df.insert(sub_df.shape[1], 'code', str(code))
    sdb.to_table(sub_df, "stock_daily_price")
    success_code_list.append(code)
    # print(sub_df)

async def update_daily_multi_io(self):
    """
    更新股票日行情数据
    东方财富日行情数据,沪深A股,先从本地配置获取股票代码,再获取日行情数据
    获取成功或失败,记录到本地数据,以便股票数据更新完整
    :return:
    """
    last_time = time.time()
    with multiprocessing.Manager() as MG:
        success_code_list = MG.list()
        except_code_list = MG.list()
        # 读取配置信息
        config_df = self.config.config_df
        if config_df.empty:
            print('配置信息错误,请检查...')
            return

        end_time = self.config.update_end_time()
        executor = ProcessPoolExecutor()
        futures = []
        loop = asyncio.get_event_loop()
        for index, row in config_df.iterrows():
            code = row['code']
            start_time = self.config.daily_start_time(code)

            if start_time == end_time:
                # 已经更新过了
                continue
            future = loop.run_in_executor(executor, self._update_daily_task,
                                          code, start_time,
                                          end_time, except_code_list,
                                          index, success_code_list)
            # future = executor.submit(self._update_daily_task,
            #                          code, start_time,
            #                          end_time, except_code_list,
            #                          index, success_code_list)
            futures.append(future)
        # wait(futures)
        if len(futures) > 0:
            await asyncio.wait(futures)

        # 更新配置信息
        for code in success_code_list:
            # 更新配置信息config_df
            config_df.loc[config_df['code'] == code, 'daily_update_time'] = end_time
            config_df.loc[config_df['code'] == code, 'error_daily_update_count'] = 0
        for code in except_code_list:
            # 更新配置信息config_df
            config_df.loc[config_df['code'] == code, 'error_daily_update_count'] \
                = row['error_daily_update_count'] + 1

        # 同步配置到本地
        self.config.save_config()
        # 手动触发去重
        sdb.optimize('stock_daily_price')

        print('更新本地配置成功...')
        print("成功请求的code: ", success_code_list)
        print("错误请求code: ", except_code_list)
        print(f'日行情更新耗时: {time.time() - last_time}')

结果:

要点分析:

进程间数据共享要通过这样子来创建,这里创建了两个列表,还有其他支持的数据类型,具体的自己查看 api.

with multiprocessing.Manager() as MG:
    success_code_list = MG.list()
    except_code_list = MG.list()

优化个股常用指标计算速度

计算的指标

指标计算是 cpu 任务密集型,所以用进程池。

本体系建立了一个指标表,这个表只记录个股自身相关的指标,分别是个股自身振幅百分比 amp 以及均线指标 ma,这两个指标包含 5、10、20、50、120、250 日的指标。

分拆任务

根据硬件自身条件 cpu 核数来启动进程池,拆分股票计算池,并分配到不同的进程,计算完毕后把指标更新到数据库。

表结构:

columns = {
        'date': 'Date',
        'code': 'String',
        'amp5': 'Float32',
        'amp10': 'Float32',
        'amp20': 'Float32',
        'amp50': 'Float32',
        'amp120': 'Float32',
        'amp250': 'Float32',
        'ma5': 'Float32',
        'ma10': 'Float32',
        'ma20': 'Float32',
        'ma50': 'Float32',
        'ma120': 'Float32',
        'ma250': 'Float32'
    }

核心代码:

def _update_common_ind_task(self, codes, start_time, end_time, success_list):
    """
    计算个股自身的的指标,股票池的形式查股票数据
    :param codes: 任务集合的股票池
    :param start_time: 开始时间
    :param end_time: 结束时间
    :param success_list: 计算成功列表
    :return:
    """

    # 查询基本日行情数据
    stocks_df = sdb.pool_stock_daily(codes, start_time, end_time, ['close'])
    if stocks_df.empty:
        return

    # 计算完的列表,用于后续拼接一次性插入数据库
    ind_df_list = []
    for code in codes:
        daily_df = stocks_df.loc[stocks_df['code'] == code]
        # 个股自身的振幅
        ind_df = mathutils.amplitude(daily_df['close'], amp5=5, amp10=10,
                                     amp20=20, amp50=50, amp120=120, amp250=250)
        # 计算个股ma
        ema_list = [5, 10, 20, 50, 120, 250]
        for i in ema_list:
            key = f'ma{str(i)}'
            ind_df[key] = ta.EMA(daily_df['close'], timeperiod=i)

        # 更新到指标表
        if not ind_df.empty:
            ind_df['code'] = code
            ind_df_list.append(ind_df)
            success_list.append(code)
        # print(f'{code} 指标计算完毕')
    result = pd.concat(ind_df_list)
    if not result.empty:
        sdb.to_indicator_table(result)
    print(f'该任务完毕,插入数据库条目数: {len(result)}')

    # 释放
    del stocks_df
    del ind_df_list

def update_common_ind(self):
    """
    更新常用指标
    :return:
    """
    # 更新常用指标
    config = self.config
    config_df = config.config_df
    format_str = '%Y-%m-%d'
    end_time = config.update_end_time()
    last_time = time.time()
    # 先去重,避免更新有冲突
    sdb.optimize(sdb.STOCK_DAILY_TABLE)
    sdb.optimize(sdb.STOCK_INDICATOR_TABLE)

    # 根据 cpu 个数拆分股票任务数
    cpu_count = os.cpu_count()
    with multiprocessing.Manager() as mg:
        # 进程内数据共享
        success_list = mg.list()
        all_codes = list(config_df['code'])
        item_count = int(len(all_codes) / cpu_count)
        print(f'每个任务计算指标个数:{item_count} codeLength: {len(all_codes)}')
        pool = multiprocessing.Pool(processes=cpu_count)
        for index in range(cpu_count):
            start_index = index * item_count
            end_index = start_index + item_count
            # 如果是最后一个任务,索引到最后
            if index == cpu_count - 1:
                end_index = len(all_codes)
            # 切片,分任务
            part_codes = all_codes[start_index: end_index]
            ind_start_time = config.ind_start_time(part_codes[0])
            # 偏移最大计算日期,最大日期 250 日均线,所以日行情数据开始位置要往前偏移
            start_time = timeutils.time_str_delta(ind_start_time, format_str, days=-365)
            if ind_start_time == end_time:
                print(f'已经计算过 时间:{end_time}')
                continue

            print(f'任务{index} 开始位置:{start_index} 结束位置:{end_index}')
            # 异步启动任务
            pool.apply_async(self._update_common_ind_task,
                             args=(part_codes, start_time, end_time, success_list))
        # 等待所有任务完毕
        pool.close()
        pool.join()
        print(f'指标计算完毕,耗时:{time.time() - last_time}')
        print(f'指标计算完毕 成功股票个数:{len(success_list)}')

        # 更新配置信息
        for code in success_list:
            config_df.loc[config_df['code'] == code, 'ind_update_time'] = end_time

    # 同步配置到本地
    self.config.save_config()
    # 操作完毕触发去重
    sdb.optimize(sdb.STOCK_INDICATOR_TABLE)

要点分析:update_common_ind() 函数是全部股票根据 cpu 核数划分任务池。_update_common_ind_task() 是任务函数,每个进程领取的部分股票池计算。

结果:

计算指标完毕后插入数据库时硬件使用情况,计算指标时可以看到 cpu 直接拉满。

参考链接

[1] 异步编程测试参考网址: https://afrankie.github.io/posts/一文搞懂python进程线程协程选择/

[2] 异步编程学习网址:https://gitee.com/paultest/asyncio_demo#协程--asyncio--异步编程

写于 2022 年 12 月 04 日 22:08:14

本文由mdnice多平台发布

posted @ 2022-12-04 23:03  醉卧梦星河  阅读(199)  评论(0编辑  收藏  举报