← 返回模块
3.6.2.3beta 可读 · 未来付费校验通过内容版本 2026-05-24

代码质量自动化:格式化、静态检查、测试与 pre-commit

3.6.2 · Git 与代码质量 · 编程

周三下午两点。一家 A-股 私募 的资深同事打开你的 MR,标题是 feat(risk): 添加 沪深300 因子-z 列至业绩归因。改了 12 个文件。二十秒之内审查线上铺满了「文件末尾多一个空行」「这个 import 没用到」「第 47 行行尾有空白」「import 没排序」之类的评论。你能感到审查时间正在漏走——这些评论没一条是关于你因子逻辑对不对的。第 2 课 教过你:人这边的审查时间是这条工作流里最稀缺的资源。本课要接上那条​​机械层​​:把上面这些细枝末节的问题在审查者看到之前全部拦住,让你用一份认真 PR 描述换来的人类注意力花在逻辑上,而不是花在风格上。

三类工具,每类一个首选

Python 这套工具链已经收敛了。这张三行表先记下来:

  • ​格式化器(formatter)​ruff format(旧默认:black)——把风格之争变成机械操作。
  • ​静态检查(linter)​ruff(旧组合:flake8 + isort + pyupgrade)——指出可疑代码但不改写。
  • ​类型检查(type checker)​mypy --strict(备选:pyright)——验证 PEP-484 类型注解前后一致。

black 是历史默认——「不妥协的代码格式化器」、几乎没有可配项、风格只此一种。ruff format 是更快的直接替代品,Rust 写成,与同一家 Astral 的 linter 集成,大代码库上快 10–100 倍。新项目挑 ruff formatblack 至今广泛存在,行为相同。Linter ruffflake8(pyflakes + pycodestyle)、isort(import 排序)、pyupgrade(按目标 Python 版本现代化语法)以及一长串 flake8-* 插件,吞并到一个用 Rust 写、速度极快、配置只有一段的工具里。最常打开的规则命名空间前缀是 E(pycodestyle 错误)、F(pyflakes)、I(isort import 顺序)、B(bugbear:可能的 bug 模式)、UP(pyupgrade:把语法现代化到目标 Python 版本)。对一个以探索性代码为主的研究仓库来说,mypy 只跑 src/、不跑 notebooks/、不跑 tests/,是务实的折衷。

测试设计(fixture、mock、parametrise、property-based)放在 3.1.3(Packaging & Testing)。本课只把 pytest -q 当作 CI 跑的那条命令;测试方法学不是本课的教学目标。

pyproject.toml:一份文件,三个工具

PEP-621 的项目元数据文件 pyproject.toml 也承担工具配置——[tool.<name>] 段。一份文件胜过散落的 setup.cfg.flake8.isort.cfg——一次 git diff、一次 PR 审查、没有令人意外的优先级。research-alpha 的 capstone 长这样:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "research-alpha"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["pandas>=2.2", "numpy>=1.26"]

[project.optional-dependencies]
dev = ["ruff>=0.5.0", "mypy>=1.11", "pytest>=8.0", "pre-commit>=3.7"]

[tool.ruff]
line-length = 100
target-version = "py311"

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
ignore = ["E501"]

从上往下读:[build-system] 声明 hatchling 作 build backend(按 3.1.3 的约定);[project] 写包元数据与现实里的运行时依赖(pandas、numpy);[project.optional-dependencies] 用一个 dev extra,使 pip install -e .[dev] 一并把整套工具拉下来;[tool.ruff]line-length 设到 100(量化团队 的常规——black 默认 88 也可以,100 让长 DataFrame 链式调用更好读)、target-version = "py311"[tool.ruff.lint] 打开上面那五个规则命名空间,ignore = ["E501"] 因为行长由格式化器接管。[tool.ruff.format] 段配 quote-style = "double" 是团队规范——为了行数 capstone 里没写,但默认假定打开。

.pre-commit-config.yaml:每次提交都跑的钩子

pre-commit 框架(pre-commit.com)的核心是一份 .pre-commit-config.yaml:列出每个 hook 与一个钉死的 rev;只要跑一次 pre-commit install,就把这些 hook 接到 .git/hooks/pre-commit 里,之后每次 git commit 都会在暂存文件上跑一遍。capstone 的样子:

repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.5.0
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.11.0
    hooks:
      - id: mypy
        args: [--strict, src/]

按顺序六个 hook、来自三个 repo 源:trailing-whitespaceend-of-file-fixercheck-yaml 来自 pre-commit/pre-commit-hooksruff(带 --fix 让它自动修能修的)与 ruff-format 来自 astral-sh/ruff-pre-commitmypy 来自 pre-commit/mirrors-mypyargs: [--strict, src/] 让它对包的源码走 strict 检查,跳过 notebooks 和 tests。rev: 字符串把每个 hook 钉到具体 tag——挑一个安静的日子 pre-commit autoupdate,永远别在周五下午动它。逃生口 git commit --no-verify 跳过 hook,但留给紧急情况(线上事故、hook 自身坏了的 hotfix);规则是「在 PR 描述里标记一下跳过,下次提交里把问题补上」。

.github/workflows/ci.yml:CI 上跑同一组检查

CI 是闸口。一份最小 GitHub Actions 工作流在每个 PR 和每次推到 main 时都跑:

name: CI
on:
  pull_request:
  push:
    branches: [main]
jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
          cache: 'pip'
      - run: pip install -e .[dev]
      - run: pre-commit run --all-files
      - run: ruff check .
      - run: mypy src/
      - run: pytest -q

逐步读:

  1. actions/checkout@v4 —— 把 PR 拉下来。
  2. actions/setup-python@v5 —— 装 Python 3.11、开 pip 缓存(下次跑就能复用 wheel)。
  3. pip install -e .[dev] —— 装包与全部 dev 工具。
  4. pre-commit run --all-files —— 「我们和本地 hook 对得上吗」的单一闸口(有些团队只留这一步、跳过后面三步;另一些四步都留,因为 GitHub Actions UI 里逐工具输出更易看)。
  5. ruff check . 跑 linter;mypy src/ 类型检查;pytest -q 跑测试套件。

main 的分支保护规则要求这道 status check 通过才能合并。GitLab CI 等价物是一份 .gitlab-ci.ymlstages: [lint, typecheck, test] 加每个 stage 一个 script: 块、命令完全一样——脚注里给个样例,不在本课系统教这套 YAML 方言。纪律明说出来:​​红色 CI 阻止合并;CI 绿灯 才合并,不是「我本机能跑」​​。

开发者体验的完整循环

三份文件 commit 之后,每天的循环就是这样:

pre-commit install
git add src/factors/risk_z.py
git commit -m "feat(factors): add risk-z column"
# hook 自动修了行尾空白;提交被中止
git add src/factors/risk_z.py
git commit -m "feat(factors): add risk-z column"

pre-commit install 每个 clone 跑一次。然后你写代码、git addgit commit。如果文件结尾带空白,trailing-whitespace hook 自动修掉、打印一次失败、把修好的文件​​留在工作区但不放回暂存区​​(hook 改的是你磁盘上的文件,不是你的 index——故意这样,让你在重新提交前再看一眼这次修了什么)。git add 一下自动修过的文件、再 commit;这一次 hook 全过,提交落地。

如果触发的是 ruff B008(默认参数里调函数),hook 不会自动修——这需要人来决定。你修代码、git add、再 commit。如果 PR 级别的 CI 在 mypy 上挂了,往同一条分支再推一个提交;GitHub Actions 监听这条 ref,CI 会自动重跑,绿了之后合并闸口就清空。如果 CI 在 pytest -q 上挂掉,是因为你拉分支之后别人改坏了 main 上的测试,那就 git pull --rebase origin main、本地重测、重新推。

Exercise

Exercise

你正在把质量闸口接到一个已有的 research-alpha 仓库上(按 L1 clone 来;按 L2 开 PR)。(a) 按 capstone 一字不差写一份 pyproject.toml,在一个全新的 .venv 里跑 pip install -e .[dev] 把 dev 工具装好。(b) 按 capstone 写一份 .pre-commit-config.yaml(6 个 hook、3 个 repo 源),跑 pre-commit install,确认 .git/hooks/pre-commit 出现了。(c) 故意构造一个违规:新增 src/factors/risk_z.py,里面只写一行 import os(未使用的 import)加一行尾空白;git add 然后 git commit -m "feat: scaffold risk_z"。确认提交被中止,原因是 ruff F401(unused import)与 trailing-whitespace 自动修。(d) 修违规:要么删 import os、要么真正用上 os,重新 git addgit commit;确认 hook 全过、提交落地。(e) 按 capstone 写一份 .github/workflows/ci.yml、commit、推到一个 feature 分支,用文字描述 pre-commit run --all-filesruff check .mypy src/pytest -q 分别在 CI runner 上验了什么——在这道闸口通过之前,PR 不能合并。

提示
(a) 里 pip install -e .[dev] 需要包能被导入;先 mkdir -p src/research_alpha、加一份空的 src/research_alpha/__init__.py,editable 安装才有东西可装。
提示
(e) 里:pre-commit run --all-files 在所有被跟踪文件上跑那 6 个 hook;ruff check . 跑 lint 规则;mypy src/ 只对包源码检查类型注解;pytest -q 以静默模式跑测试套件。四者合起来构成合并闸口。

模块到此收束

这是 Subject 3.6 中​​最小​​的一个模块——这是有意的:git 是工具,不是一个领域,三节课足以让学习者不再卡住,又不至于膨胀成一份教程。你现在拿到了底座(第 1 课)、团队层(第 2 课)、机械层(本课)。Subject 3.6 接下来是 3.6.3(SQL 与 时序数据库)、3.6.4(Messaging 与 流式处理)、3.6.5(Build / Deploy / Containers)、3.6.6(可观测性 与 系统设计);每一节都假设你已经能 clone 一个仓库、开一条特性分支、写一段干净的提交历史、走通代码审查、并相信 CI 把好合并这一关——恰好是这个模块刚刚买给你的能力。

阅读清单

  • ruff 用户指南(docs.astral.sh/ruff),部分页面有社区中文翻译——配置、规则手册、formatter。
  • black 中文 README,旧但仍在很多仓库里活跃的格式化器。
  • mypy 用户指南 关于 --strictmypy.ini 的章节(mypy.readthedocs.io)。
  • pre-commit 中文 README(社区翻译,pre-commit.com)。
  • GitLab 中文 文档 关于 .gitlab-ci.yml 的 快速开始。
  • 一篇 在 A-股 量化社区 被广泛 转载 的「pyproject.toml + ruff + pre-commit 三件套」实用文章。

一条额外注释:A-股 量化团队 的工程化栈基本是 内部 GitLab + 内部 Nexus + 内部 PyPI 镜像;pip install -e .[dev] 通常走 pip install --index-url https://nexus.firm.local/repository/pypi/simple/ -e .[dev],既加速又满足合规。一句话带去未来每个仓库:​​能自动化的检查就别让人去看;人 只看 逻辑;CI 绿灯 才合并​​。

速查卡

三份 capstone 文件加开发循环定义了本课——抄进自己的笔记:

  • Fenced ```toml 块——五段 pyproject.toml[build-system][project][project.optional-dependencies][tool.ruff][tool.ruff.lint])。
  • Fenced ```yaml 块——.pre-commit-config.yaml(三 repo 源、六个 hook)与 .github/workflows/ci.yml(一个 lint-and-test job、五步、四道闸口)。
  • Fenced ```bash 块——六行开发体验循环:pre-commit install 到 hook 中止再到重新提交。
  • Inline 三行表——格式化器 ruff format(旧 black)、静态检查 ruff、类型检查 mypy --strict
  • Exercise——把质量闸口接进 research-alpha,附 Two 条渐进 Hints;CI 全绿才合并。