周三下午两点。一家 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 format;black 至今广泛存在,行为相同。Linter ruff 把 flake8(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-whitespace、end-of-file-fixer、check-yaml 来自 pre-commit/pre-commit-hooks;ruff(带 --fix 让它自动修能修的)与 ruff-format 来自 astral-sh/ruff-pre-commit;mypy 来自 pre-commit/mirrors-mypy,args: [--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
逐步读:
actions/checkout@v4—— 把 PR 拉下来。actions/setup-python@v5—— 装 Python 3.11、开 pip 缓存(下次跑就能复用 wheel)。pip install -e .[dev]—— 装包与全部 dev 工具。pre-commit run --all-files—— 「我们和本地 hook 对得上吗」的单一闸口(有些团队只留这一步、跳过后面三步;另一些四步都留,因为 GitHub Actions UI 里逐工具输出更易看)。ruff check .跑 linter;mypy src/类型检查;pytest -q跑测试套件。
main 的分支保护规则要求这道 status check 通过才能合并。GitLab CI 等价物是一份 .gitlab-ci.yml,stages: [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 add、git 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 add、git commit;确认 hook 全过、提交落地。(e) 按 capstone 写一份 .github/workflows/ci.yml、commit、推到一个 feature 分支,用文字描述 pre-commit run --all-files、ruff check .、mypy src/、pytest -q 分别在 CI runner 上验了什么——在这道闸口通过之前,PR 不能合并。
提示
pip install -e .[dev] 需要包能被导入;先 mkdir -p src/research_alpha、加一份空的 src/research_alpha/__init__.py,editable 安装才有东西可装。提示
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用户指南 关于--strict与mypy.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-testjob、五步、四道闸口)。 - Fenced
```bash块——六行开发体验循环:pre-commit install到 hook 中止再到重新提交。 - Inline 三行表——格式化器
ruff format(旧black)、静态检查ruff、类型检查mypy --strict。 - Exercise——把质量闸口接进
research-alpha,附 Two 条渐进 Hints;CI 全绿才合并。