前端get到的新技能--手把手教你使用Python实现查询基金年度排名功能

263 阅读14分钟

背景

做前端好多年了, 在掘金前端热榜上,刷到的好多文章,都是旧饭新炒。这种情况说明, 前端技术已从蓝海变成红海,需要寻找新的蓝海,才能比较easy的获取到大量的新知识。那么该选择哪门编程语言作为下一个目标呢,个人感觉Python比较有前途,因为当下比较火热的AI大模型领域,编程语言用的就是Python。目标选定好了之后,我们写一个查询年度基金排名的入门级应用,练练手。废话不多说了,现在我们进入正题。

第一步 安装Python及项目依赖包

1. 安装Python

下载win最新版的Python,安装时记得勾选Add python.exe to PATH

image.png

2. 安装项目依赖的Python包

这里说两点:第一点,给pip配置永久镜像源,以提升下载速度,解决连接问题。第二点,要做环境隔离。Python项目安装依赖包时,建议设置环境隔离。

1.配置永久镜像源

常用国内镜像源有:

国内镜像源地址
清华大学pypi.tuna.tsinghua.edu.cn/simple
阿里云mirrors.aliyun.com/pypi/simple
中国科技大学pypi.mirrors.ustc.edu.cn/simple
华中理工大学pypi.hustunique.com/simple
豆瓣pypi.douban.com/simple

可以采用ping指令测试各个源的响应速度,例如

ping pypi.tuna.tsinghua.edu.cn
ping mirrors.aliyun.com
// ...

经过测试,发现阿里云比较快,所以将pip镜像源配置成阿里源。打开Windows: C:\Users\<你的用户名>\pip\pip.ini文件,如果没有,可以手动创建pip文件夹,并在此文件夹下创建一个pip.ini文件。填写如下内容:

[global]
index-url = https://mirrors.aliyun.com/pypi/simple

配置完之后,可以用下面的命令验证配置是否成功

pip config list

2.设置环境隔离

为什么需要环境隔离?不同项目可能依赖不同版本的库,比如: 项目A需要 pandas 版本1.3, 项目B需要 pandas 版本2.0; 如果你在系统全局安装包,没有隔离,会产生冲突,导致代码跑不起来。隔离后,每个项目的依赖包版本互不干扰,代码环境干净且可控。

如何做环境隔离呢,采用下面的命令:

cd 项目目录
python -m venv venv        # 创建虚拟环境
source venv/Scripts/activate   # win激活虚拟环境
pip install 依赖包         # 安装依赖
pip freeze > requirements.txt  # 记录依赖
deactivate  # 退出虚拟环境

# 下次打开项目
source venv/Scripts/activate
# 团队成员拉取新项目时
source venv/Scripts/activate
pip install -r requirements.txt

第二步 编写查询基金年度排名功能

1. 编写主体功能

整理思路就是:

  1. 从所有基金中筛选出可以进行T+0交易的基金。
  2. 对符合条件的基金进行排名,打印出排名前三的基金

对于T+0的判断逻辑,这里要说明一下:A股市场中,ETF的属性字段没有直接是标注“T+0”还是“T+1”交易。要判断一个ETF是支持T+0交易,主要是基于以下几个关键规则:

  • 跨境ETF:只要是投资境外市场(例如:港股、美股、日股、德股等)的ETF,并且在境内(沪深两市)上市,通常都可以进行T+0交易。这是因为这类ETF的底层资产在境外,与A股市场的交易时间存在时差,为了方便投资者交易,监管机构允许其当日买入后当日卖出。
  • 黄金ETF:在沪深交易所上市的黄金ETF,如华安黄金ETF(518880),也属于T+0交易品种。
  • 货币基金ETF:在交易所上市的货币市场基金,如华宝添益(511990),也支持T+0交易。
  • 商品期货ETF:跟踪国内商品期货指数的ETF,比如豆粕ETF、原油ETF等,也属于T+0交易。

通过关键词匹配的方式来筛选,是一个非常有效的、也是目前最主流的实现方式。

初始版本是:

import akshare as ak
import pandas as pd
from datetime import datetime

def get_qdii_etf_rank():
    try:
        df = ak.fund_etf_fund_daily_em()

        # 过滤含有海外、全球、港股、美股、纳指等关键词
        df = df[df["基金简称"].str.contains("全球|纳指|标普|恒生|港股|美股|道琼斯|国际|MSCI|海外", na=False)]

        # 提取并转换“近1年”列
        df["近1年"] = pd.to_numeric(df["近1年"].str.rstrip('%'), errors="coerce")

        # 排序取前10
        top10 = df.sort_values(by="近1年", ascending=False).head(10)

        print(f"\n[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 全球ETF近一年涨幅前10:\n")
        print(top10[["基金代码", "基金简称", "基金类型", "近1年"]].to_string(index=False))
    except Exception as e:
        print(f"获取 ETF 数据失败:{e}")

if __name__ == "__main__":
    get_qdii_etf_rank()

这个Python脚本用到了两个外部依赖包,它们的功能如下:

模块导入名pip 安装包名用途说明
akshareakshare提供财经数据接口库,可获取股票、基金、期货、宏观等金融数据
pandaspandas提供强大的数据处理、表格处理能力,例如 DataFrame。适用于读写 CSV、Excel、数据分析等。

执行安装命令进行安装,如果安装成功,但运行报错,大概率因为Python版本与依赖包依赖的包版本不兼容,需要把Python升级到最新版本。

# 安装依赖
pip install akshare pandas
# 验证安装是否成功
python -c "import akshare, pandas, requests; print('All packages are installed')"

安装时如果报错 ERROR: Could not find C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe,说明系统没有安装 Visual Studio 的构建工具(Build Tools for Visual Studio),无法完成 pandas 的编译安装。Visual Studio 编译工具(Build Tools)不是操作系统的一部分,而是一个 专门为开发者提供的工具集。它默认不会和操作系统一起安装,原因如下:

原因说明
Windows 是通用操作系统Windows 是为所有用户设计的,不是每个人都需要编译软件。普通用户用不到编译器。
安装包体积大Visual Studio Build Tools 包含 C++ 编译器、链接器、SDK 等,体积可能超过 3~5 GB,不适合默认集成。
按需安装理念微软采用“按需安装”策略,只为需要开发工具的用户提供。
开发工具更新频繁Visual Studio 的编译工具更新周期独立于 Windows,分开安装便于独立升级和维护。

为了使编译能够通过,需要手动安装 Visual Studio Build Tools(最小化安装即可):

2. 更换数据源

查询A股所有的ETF时,老是报错: Connection aborted., ConnectionResetError(10054, '远程主机强迫关闭了一个现有的连接, 意思说客户端成功地向数据服务器(远程主机)发起了连接请求,但服务器那边因为某种原因,在数据还没传输完的时候就单方面切断了这个连接。发现是ak.fund_etf_fund_info_em()引起的,ak.fund_etf_fund_info_em() 这个函数是从东方财富(em)获取数据的。akshare 库非常灵活,它常常提供来自不同数据源(如东方财富、新浪财经、腾讯财经)的接口。当一个源不稳定时,可以尝试更换另一个。可以用 akshare 中另一个获取ETF实时行情列表的函数 ak.fund_etf_spot_em() 来代替它。虽然这个函数主要用于获取实时价格,但它同样返回了一个包含代码和名称的ETF列表,足以满足筛选的需求。

3. 手动计算排名

更换数据源之后,获取ETF列表没有问题了,可是查询排名的时候又报错

第三步:正在获取所有基金的业绩排名数据...

❌ 第 1 次尝试获取基金排名数据失败: ('Connection aborted.', ConnectionResetError(10054, '远程主机强迫关闭了一个现有的连接。', None, 10054, None))

   5秒后将进行重试...

❌ 第 2 次尝试获取基金排名数据失败: ('Connection aborted.', ConnectionResetError(10054, '远程主机强迫关闭了一个现有的连接。', None, 10054, None))

   5秒后将进行重试...

❌ 第 3 次尝试获取基金排名数据失败: ('Connection aborted.', ConnectionResetError(10054, '远程主机强迫关闭了一个现有的连接。', None, 10054, None))

原因是调用东方财富出在调用ak.fund_open_fund_rank_em() 上。这个函数和最初失败的函数一样,都是从东方财富(em)获取数据,因此也同样遭到了网络拦截。既然直接获取“基金排名”这个大表格的路被堵死了,就换一种思路:不拿现成的排名表了,而是自己动手,一个一个地去查询符合条件的ETF过去一年的历史价格,然后手动计算出它们的涨跌幅。

这个方法虽然慢一点,但有两大优势:

  • 查询单个ETF历史数据是小请求,不容易被防火墙拦截。
  • 即使中途有几个ETF查询失败,程序也不会崩溃,可以跳过它们继续运行,最终能得到一个有效的结果。

将基础版修改为:

import pandas as pd
import akshare as ak
import time
from datetime import datetime, timedelta

def find_top_t0_global_etfs_professional():
    # --- 第一步:获取ETF列表 ---
    print("第一步:正在获取所有A股ETF基金列表 (使用备用接口)...")
    try:
        etf_info_df = ak.fund_etf_spot_em()
        etf_info_df.rename(columns={'名称': '基金简称'}, inplace=True)
        print("✅ ETF列表获取成功!")
    except Exception as e:
        print(f"❌ 获取ETF列表失败: {e}")
        return

    # --- 第二步:筛选全球ETF (扩展关键词) ---
    print("\n第二步:正在筛选潜在的T+0全球ETF...")
    # 关键改动:在关键词列表中加入“香港”
    keywords = ['纳指', '标普', '恒生', 'H股', '中概', '日经', '德国', '法国', '美国', '港股', '全球', '亚太', '越南', '印度', '香港']
    pattern = '|'.join(keywords)
    global_etfs_df = etf_info_df[etf_info_df['基金简称'].str.contains(pattern, na=False)].copy()

    if global_etfs_df.empty:
        print("   未能找到符合条件的全球ETF。")
        return
    print(f"   已找到 {len(global_etfs_df)} 个潜在的T+0全球ETF。")
    
    # --- 第三步:手动计算近一年涨跌幅 (增加专业过滤器) ---
    print("\n第三步:正在逐个获取ETF历史数据并计算近一年涨跌幅...")
    
    end_date = datetime.now()
    start_date = end_date - timedelta(days=365)
    end_date_str = end_date.strftime('%Y%m%d')
    start_date_str = start_date.strftime('%Y%m%d')
    
    results = []
    total_etfs = len(global_etfs_df)


    for index, row in global_etfs_df.iterrows():
        etf_code = row['代码']
        etf_name = row['基金简称']
        print(f"   正在处理 ({index+1}/{total_etfs}): {etf_code} {etf_name}...")
        
        try:
            hist_df = ak.fund_etf_hist_em(symbol=etf_code, period="daily", start_date=start_date_str, end_date=end_date_str, adjust="qfq")
            

            if not hist_df.empty and len(hist_df) :
                start_price = hist_df.iloc[0]['收盘']
                end_price = hist_df.iloc[-1]['收盘']
                yearly_return = ((end_price / start_price) - 1) * 100
                
                results.append({
                    '代码': etf_code,
                    '基金简称': etf_name,
                    '近1年涨跌幅': yearly_return
                })
                print(f"     ✅ 计算成功: {yearly_return:.2f}%")
       
            
            time.sleep(0.5)

        except Exception as e:
            print(f"     ❌ 获取或计算失败,跳过。错误: {e}")
            continue

    if not results:
        print("\n未能成功计算任何ETF的涨跌幅。")
        return

    # --- 第四步:排序和展示结果 ---
    print("\n第四步:正在对结果进行排序...")
    final_df = pd.DataFrame(results)
    top_3_etfs = final_df.sort_values(by='近1年涨跌幅', ascending=False).head(3)

    print("\n==================== 查询结果 (专业版) ====================")
    print("近一年涨跌幅前三的可T+0交易全球ETF为:")
    print("---------------------------------------------------------")

    if top_3_etfs.empty:
        print("在上市的基金中,没有找到符合条件的ETF数据。")
    else:
        top_3_etfs.reset_index(drop=True, inplace=True)
        for index, row in top_3_etfs.iterrows():
            print(f"🏆 排名 {index + 1}:")
            print(f"   - 基金代码: {row['代码']}")
            print(f"   - 基金简称: {row['基金简称']}")
            print(f"   - 近一年涨跌幅: {row['近1年涨跌幅']:.2f}%")
            print("---------------------------------------------------------")

    print("\n⚠️ 免责声明: 数据仅供参考,不构成投资建议,市场有风险,投资需谨慎。")


if __name__ == '__main__':
    find_top_t0_global_etfs_professional()

执行python main.py, 得到的结果是:

近一年涨跌幅前三的可T+0交易全球ETF为:
---------------------------------------------------------
🏆 排名 1:
   - 基金代码: 159570
   - 基金简称: 港股通创新药ETF
   - 近一年涨跌幅: 133.74%
---------------------------------------------------------
🏆 排名 2:
   - 基金代码: 513090
   - 基金简称: 香港证券ETF
   - 近一年涨跌幅: 133.37%
---------------------------------------------------------
🏆 排名 3:
   - 基金代码: 159567
   - 基金简称: 港股创新药ETF
   - 近一年涨跌幅: 128.97%
---------------------------------------------------------

和同花顺查询的结果对比了一下, 同花顺显示的结果是

1. 513090 | 香港证券ETF   | 134.88%
2. 159570 | 港股创新药ETF | 127.33%
3. 159567 | 港股创新药ETF | 122.98%

4. 结果差异原因

结果有些差异,造成这些差异的原因有:

  • 近一年”的计算基准日不同

    • 代码start_date = datetime.now() - timedelta(days=365),这会精确地从今天向前推365天。
    • 同花顺:同花顺的数据更新可能是每日收盘后,或者在某些特定时间点。它所定义的“近一年”可能是从今天(交易日)的收盘价与一年前同期交易日的收盘价进行对比。例如,如果一年前的今天是一个非交易日,同花顺可能会使用上一个交易日的数据。这种细微的时间差异就会导致计算结果不同。
  • 复权方式的细微差异

    • 代码ak.fund_etf_hist_em(..., adjust="qfq"),使用的是前复权
    • 同花顺:同花顺也使用复权数据,但其具体复权算法(比如对分红、拆分等事件的处理细节)可能与akshare接口所采用的底层数据源略有不同。尽管绝大多数情况下差异很小,但在某些特定ETF上,累积起来的微小差异就可能影响最终的百分比。

总结来说,代码逻辑是完全正确的。出现差异是由于数据源、计算基准日和复权细节上的不同。这种差异在金融数据分析中很常见,只要逻辑严谨,代码计算结果就是可靠的。

第三步 每天定时运行

将主程序及其依赖包打包成docker镜像镜像,设置一个Jenkins定时任务,每天定期执行

# 使用官方Python运行时作为基础镜像
FROM python:3.13-slim

# 设置工作目录
WORKDIR /app

# 将requirements.txt复制到容器中
COPY requirements.txt .

# 安装Python依赖
RUN pip install -i https://mirrors.aliyun.com/pypi/simple/ --no-cache-dir -r requirements.txt

# 将项目文件复制到容器中
#COPY . .

# 设置环境变量(可选)
# ENV PYTHONPATH=/app
# 让Python日志实时输出
ENV PYTHONUNBUFFERED=1

# 暴露端口(如果需要的话)
# EXPOSE 8000

# 运行应用
#CMD ["python", "main.py"]
pipeline {
  agent any

  // 定时器配置:每天9:00, 14:00触发
  triggers {
      cron('H 9,14 * * *')
  }

  environment {
    REGISTRY_HOST = 'reg.xxx.com:9088'
    IMAGE_PATH = 'base-images/common-project'
    IMAGE_NAME = 'reg.xxx.com:9088/base-images/common-project'
    IMAGE_TAG = 'python-base'
    CONTAINER_NAME = 'get-news-container'
  }

  stages {
    stage('Checkout') {
      steps {
        // 检出代码
        checkout scm
      }
    }

    // stage('诊断') {
    //   steps {
    //     sh 'docker info'
    //     sh 'pwd'
    //   }
    // }
    stage('Check and Build Docker Image') {
      steps {
        script {
          // 检查镜像是否存在,不存在则构建
          def imageExists = sh(
              script: "docker images -q ${IMAGE_NAME}:${IMAGE_TAG}",
              returnStdout: true
          ).trim()

          if (!imageExists) {
            withCredentials([
              usernamePassword(
                credentialsId: 'REGISTRY',
                usernameVariable: 'REGISTRY_USERNAME',
                passwordVariable: 'REGISTRY_PASSWORD'
              )
            ]) {
              sh """
                echo "\$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOST" -u "\$REGISTRY_USERNAME" --password-stdin
                docker build -t $REGISTRY_HOST/$IMAGE_PATH:$IMAGE_TAG .
                docker push $REGISTRY_HOST/$IMAGE_PATH:$IMAGE_TAG
              """
            }
          } else {
            echo '镜像已存在,跳过构建步骤'
          }
        }
      }
    }

    stage('运行应用') {
      steps {
        script {
          // sh "ls -l ${WORKSPACE}"

          // 1. 启动一个临时容器,使用 `tail -f /dev/null` 让它保持运行
          sh """
            docker run -d --name ${CONTAINER_NAME} ${IMAGE_NAME}:${IMAGE_TAG} tail -f /dev/null
          """

          // 2. 将 Jenkins 工作目录下的所有文件复制到容器内部的 /app 目录
          sh """
            docker cp ${WORKSPACE}/. ${CONTAINER_NAME}:/app
          """

          // 3. 在容器内执行 Python 脚本
          sh """
            docker exec ${CONTAINER_NAME} python /app/main.py > /dev/null 2>&1
          """

          // 4. 将容器内生成的文件复制回 Jenkins 工作目录,实现持久化
          sh """
            docker cp ${CONTAINER_NAME}:/app/last_top_etf.json ${WORKSPACE}/last_top_etf.json
          """
        }
      }
    }
  }

  post {
    always {
      // 清理容器
      script {
        sh "docker stop ${CONTAINER_NAME} || true"
        sh "docker rm ${CONTAINER_NAME} || true"
      }
    }
    failure {
      // 失败时清理容器
      echo '执行失败!'
    }
    aborted {
      // 失败时清理容器
      echo '终止执行'
    }
    success {
      echo '执行成功'
    }
  }
}

最后

在实现这个功能的过程中,在一个问题上卡了很久,就是将Jenkins的工作空间挂载到Docker容器中,用Docker标准的-v, -mount指令挂载始终不成功,最后发现Jenkins工作空间也是个Docker容器,相当于把Docker容器中的文件挂载到Docker容器中,原理是:

  • 外部 Docker 容器(Jenkins) :Jenkins 运行在一个 Docker 容器里。
  • 内部 Docker 容器(您的项目) :Jenkins 容器又去执行 docker run 命令,启动了另一个 Docker 容器来运行您的项目。

在这种“容器套容器”的复杂场景中,当在 Jenkins 容器内部执行 docker run -v <path> 时,这个 <path> 指向的是 Jenkins 容器内部的文件路径。但 Docker 守护进程(Docker Daemon)通常运行在宿主机上。

这就导致了路径的错乱:

  • Jenkins 容器内:文件路径是 /var/jenkins_home/workspace/get-news
  • 宿主机(Docker Daemon 所在) :这个路径 /var/jenkins_home/workspace/get-news 在宿主机上并不存在,或者指向一个不相关的目录。

Docker Daemon 尝试在宿主机上找到这个路径来挂载,但由于找不到,就会导致挂载失败,并报错 not a directoryNo such file or directory

最后采用docker cp复制文件的方式,解决了这个问题。 docker cp不依赖于底层的文件路径映射,它直接通过 Docker API 在运行中的容器之间复制文件。这使得它在“Docker in Docker”或任何复杂的容器化环境中都能稳定工作。本文完