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

量化部署中的 Python 项目打包

3.6.5 · 构建、部署与容器化 · 编程

上海一家 私募 的初级 quant 刚完成 3.6.4 的 capstone:三个 Python 文件、一个 run.sh、一份装满了沪深300 ETF tick 的 TimescaleDB 仓库。PM 很满意;平台组不太满意。部署评审会上他们问的第一个问题是「wheel 在哪?」第二个问题是「锁定文件 在哪?」那段在开发者笔记本上跑得完美的脚本,没有办法在测试集群里以同样的方式安装、并保证 confluent-kafkapsycopg 会解析到一致的版本。它没有版本字符串,没有 CHANGELOG.md,也没有干净的容器构建上下文。平台组不会部署。从本模块开始你交付的一切——L2 的 OCI 镜像、L3 的 Kubernetes Deployment、L4 的 CI 流水线——输入都是一份 可部署 工件。那份工件就是一个 wheel 加一个锁定文件,本课就是把它产出来的地方。

为什么 是 pyproject.toml

setup.py 的时代结束了。PEP 517(构建系统规范)、PEP 518(构建时依赖)、PEP 621(项目元数据)合在一起把 setup.py 换成了一个声明式文件:pyproject.toml。量化 服务要紧的有四个段。[build-system] 指定 构建后端——也就是把源代码树变成 wheel 的代码。[project] 承载 PyPI、安装器、运维都会读的元数据:nameversionrequires-pythondependenciesauthorslicensereadme[project.optional-dependencies] 暴露像 dev 这样的 extras,使 pip install feed-handler[dev] 能装上开发工具而不会把生产安装撑大。[project.scripts] 暴露控制台脚本入口点:pip install feed-handler 会生成 ~/.local/bin/feed-handler,指向 feed_handler.__main__:main

feed-handler 服务的标准文件,完整:

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

[project]
name = "feed-handler"
dynamic = ["version"]
description = "Quant market-data feed handler (Kafka producer, consumer, monitor)"
requires-python = ">=3.12"
readme = "README.md"
license = {text = "MIT"}
dependencies = [
    "confluent-kafka==2.4.0",
    "psycopg[binary]==3.2.1",
    "pyzmq==26.0.3",
]

[project.optional-dependencies]
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.10"]

[project.scripts]
feed-handler = "feed_handler.__main__:main"

[tool.hatch.version]
path = "src/feed_handler/__init__.py"

构建后端 的取舍

生产里在用的有四个。hatchling 是新项目的推荐默认:现代、PEP-621 原生、快、src 布局无需额外配置。setuptools.build_meta 是兼容路径,只有当你手上有一份退不掉的 setup.py 时才留。poetry.core.masonry.api 是 Poetry 原生后端,给已经吃透 poetry add / poetry run 工作流的团队。pdm.backend 是 PDM 原生后端,附带 PEP-582 支持。没有特殊理由的话就选 hatchling;产出的 wheel 在四个后端之间可互换。

版本是 唯一 真理源

[tool.hatch.version] path = "src/feed_handler/__init__.py" 在构建时从 __init__.py 里读版本号。一个文件即权威:

"""Quant market-data feed handler."""
__version__ = "1.0.0"

wheel 文件名 (feed_handler-1.0.0-py3-none-any.whl)、容器镜像标签 (feed-handler:1.0.0)、包元数据读的都是这一条字符串。不用手抄,不会漂移。改这一行,重新构建,下游所有工件都自然对齐。

sdist 与 wheel

python -m builddist/ 下产出两份文件。sdist——feed_handler-1.0.0.tar.gz——是源码归档,只有当目标平台没有匹配的预构建 wheel 时 pip install 才会用它。wheel——feed_handler-1.0.0-py3-none-any.whl——是二进制安装格式,是生产部署真正消费的那一份。规则只说一次、永不违反:​​wheel 才是 工件​​。CI 一次构建出 wheel,对着 wheel 跑测试、扫描、签名,容器的 COPY 步直接拿那份 wheel——绝不在镜像构建时再去 PyPI 重新解析依赖。

锁定文件 详解

依赖可重现性 是 量化 团队 损失两天 给 「在我笔记本上能跑」bug 的根源。pip freeze > requirements.txt ​不是​ 锁定文件:没有加密 hash(被攻陷的镜像源能塞替换包)、没有平台标签(换个 OS 或 Python 版本 pip install 可能选到不同版本)、没有传递约束求解状态(重新解析会得到不一样的输出)。2026 年 业界的四个标准锁定文件 生成器,按这个顺序:

  • pip-toolspip-compile requirements.in --generate-hashes -o requirements.txt + pip-sync requirements.txt——历史最长、最稳的选择,产出 hash 钉死 的 requirements.txt
  • poetrypoetry lock + poetry install --no-update——和 poetry add 集成做依赖管理,2018–2024 工程化 较强 团队的 modal 选择。
  • pdmpdm lock + pdm sync——可选 PEP-582 __pypackages__ 布局,用户基数较小。
  • uvuv lock + uv sync --frozen——Rust 实现,比 pip-tools 快约 100 倍,2026 年新量化 项目的推荐默认。

四个工具共通的运维规则:​​CI 里 对着锁定文件跑测试;生产 从锁定文件部署​​。CI 跑 uv sync --frozen(或对应命令)所以测试套件跑在固定的依赖树上;生产用同一份锁定文件、同一个 sync 命令部署;绝不要在生产里 没有锁定文件 居中 就直接 pip install <package>

语义化 版本,三个触发条件

MAJOR.MINOR.PATCH,标准规则。MAJOR——破坏性 API 变更(重命名函数、删除参数、改变返回类型)。MINOR——向后兼容的新特性(新函数、带默认值的可选参数、新的子命令)。PATCH——bug 修复(off-by-one、不改外部行为的逻辑修正)。和 3.6.2 的 PR 评审 工作流挂钩:版本号 bump 和 CHANGELOG.md 条目按 3.6.2 同一个 PR 一起落;评审者验证 bump 等级与实际 diff 匹配。版本号 是 feed_handler/__init__.py 内唯一的真理源;hatchling 通过 [tool.hatch.version] 读它,wheel 文件名、容器镜像 tag、包元数据 都自然对齐,不需要手工同步。

__main__.py 入口点 约定

每个可部署服务都暴露一个模块级入口点,这样容器的 ENTRYPOINT 就成了 ["python", "-m", "feed_handler"]——不绕 shell、不 bash -c、不在 entrypoint 字符串里展开环境变量、不会因为 shell 当 init 而出现 PID-1 信号问题。运行时的参数选子命令(Containerfile 里 CMD ["consumer"],在编排层覆盖成 ["producer"]["monitor"])。分发器 20 行:

"""Entry point for `python -m feed_handler <subcommand>`."""
import sys

VALID_SUBCOMMANDS = {"producer", "consumer", "monitor"}


def main():
    if len(sys.argv) < 2 or sys.argv[1] not in VALID_SUBCOMMANDS:
        print(f"usage: python -m feed_handler {{{'|'.join(sorted(VALID_SUBCOMMANDS))}}}", file=sys.stderr)
        sys.exit(2)
    subcommand = sys.argv[1]
    if subcommand == "producer":
        from feed_handler.producer import main as producer_main
        producer_main()
    elif subcommand == "consumer":
        from feed_handler.consumer import main as consumer_main
        consumer_main()
    elif subcommand == "monitor":
        from feed_handler.monitor import main as monitor_main
        monitor_main()


if __name__ == "__main__":
    main()

集合 {"producer", "consumer", "monitor"} 对应三个运行时子命令;三个目标函数 feed_handler.producer.main / feed_handler.consumer.main / feed_handler.monitor.main 是分发器需要的全部句柄。

端到端 实例

把 3.6.4 L4 的单文件 feed_handler.py 拿过来,重构进 src/feed_handler/。把 producer 主体挪到 producer.pydef main(): ...。consumer 主体挪到 consumer.py。VWAP 监控挪到 monitor.py__init__.py 写上 __version__ = "1.0.0"__main__.py 写上上面的分发器。按 4 段写好 pyproject.toml。然后五条命令的 build-install-run 序列,按这个顺序:

uv lock
python -m build
uv venv .venv && source .venv/bin/activate
uv pip install ./dist/feed_handler-1.0.0-py3-none-any.whl
python -m feed_handler producer

uv lock 产出 uv.lock,里面带每一条传递依赖的 hash。python -m build 拉起 hatchling,产出 dist/feed_handler-1.0.0-py3-none-any.whldist/feed_handler-1.0.0.tar.gz。崭新的 venv 证明 wheel 在不依赖源码树的环境里能干净安装。最后一条 python -m feed_handler producer 产出和 3.6.4 L4 单文件版本一致的 JSON tick 流——证明 包结构 重构 没有 改变 行为。

私有 包 索引

国内 量化 firm 把 wheel 推到自建 私有 索引,不推 公网 PyPI。自建 Nexus / Artifactory 是 大型 私募 与 券商 的 modal 选择;阿里云 Codeup 制品 仓库 / 腾讯云 CODING / 华为云 软件开发 生产线 (CodeArts) 是 公有云 团队 的 选项。配置 上 通常 把 内部 索引 同时 作为 PyPI 镜像 (pip config set global.index-url <mirror>,或在 pyproject.toml 内的 [[tool.uv.index]] 块声明私有 上游);阿里 mirror mirrors.aliyun.com/pypi/simple/ 与 清华 mirror pypi.tuna.tsinghua.edu.cn/simple 也是常用的上游代理,避免公网 PyPI 在内网受限。wheel 命名 (feed_handler-1.0.0-py3-none-any.whl) 与上传机制 (twine upload --repository-url ...) 与 PyPI 端 一致;只是端点变了。trusted-publishers / OIDC 发布 作为 免凭据 CI 上传 的 生产 路径,点到为止。

纪律 总结

pyproject.toml 是 真理源。wheel 是 可部署 工件。锁定文件 是 可重现性 印章。CI 一次 构建 wheel 并 对 wheel 跑 测试,绝不 在 CI 里 跑 pip install -e .。版本 bump 与 CHANGELOG.md 条目 按 3.6.2 同 PR 落地。__main__.pypython -m service_name 给容器一个不绕 shell 的干净入口。

练习

Exercise

取 3.6.4 L4 的单文件 feed_handler.py(若跳过 3.6.4,则用本课提供的等价合成 Python 脚本),并 (a) 重构为包布局 src/feed_handler/__init__.py(携带 __version__ = "1.0.0")、src/feed_handler/producer.py(producer 逻辑,暴露 def main():)、src/feed_handler/consumer.pysrc/feed_handler/monitor.pysrc/feed_handler/__main__.py(上面的 20 行分发器)。(b) 按本课四段写 pyproject.toml,固定 confluent-kafka==2.4.0psycopg[binary]==3.2.1pyzmq==26.0.3。(c) 跑 uv lock 并验证 uv.lock 已生成,且每条传递依赖都带 hash。(d) 跑 python -m build,确认 dist/feed_handler-1.0.0-py3-none-any.whldist/feed_handler-1.0.0.tar.gz 都被产出。(e) 在崭新 venv 内用 uv pip install ./dist/feed_handler-1.0.0-py3-none-any.whl 安装 wheel(​​不是​ pip install -e .),跑 python -m feed_handler producer,确认输出与 3.6.4 L4 脚本一致的 JSON tick 流。(f) 把 src/feed_handler/__init__.py 内的版本 ​只​ 改成 1.0.1,重新跑 python -m build,确认新 wheel 文件名是 feed_handler-1.0.1-py3-none-any.whl——证明 dynamic = ["version"]__init__.py 成为唯一真理源。(g) 在 CHANGELOG.md 添加 1.0.1 条目,用一句话说明为什么是 PATCH 级 bump(无 API 变更)。

提示
src/ 布局起步:wheel 构建只看 src/feed_handler/ 下面。若 python -m build 报 「no module named feed_handler」,检查 __init__.py 是否放在 src/feed_handler/ 里,而不是放在 pyproject.toml 旁边。
提示
版本 bump 必须是 PATCH 级,因为没有公开 API 变更——只是 producer 的日志格式或一个 bug 修复。如果你重命名了函数或删除了参数,就该是 MAJOR;如果你加了子命令,就是 MINOR。

必备 组件 回顾

本课 交付物 对 合约 的 映射:

  1. Fenced ```toml 块——标准 pyproject.toml,四 段([build-system][project][project.optional-dependencies][project.scripts]),三 个 钉死 依赖(confluent-kafka==2.4.0psycopg[binary]==3.2.1pyzmq==26.0.3),requires-python = ">=3.12"dynamic = ["version"][tool.hatch.version] path = "src/feed_handler/__init__.py"
  2. Fenced ```python 块——src/feed_handler/__init__.py,一行 模块 docstring 加 __version__ = "1.0.0"
  3. Fenced ```python 块——src/feed_handler/__main__.pyVALID_SUBCOMMANDS = {"producer", "consumer", "monitor"} 集合,分发 到 feed_handler.producer.main / feed_handler.consumer.main / feed_handler.monitor.main,加 if __name__ == "__main__": main() 守卫。
  4. Fenced ```bash 块——五 条 build + install + run 顺序(uv lockpython -m builduv venv .venv && source .venv/bin/activateuv pip install ./dist/feed_handler-1.0.0-py3-none-any.whlpython -m feed_handler producer)。
  5. Inline-code 列表 四 个 标准 锁定文件 生成器,按 这个 顺序:pip-toolspip-compile requirements.in --generate-hashes -o requirements.txt + pip-sync requirements.txtpoetrypoetry lock + poetry install --no-updatepdmpdm lock + pdm syncuvuv lock + uv sync --frozen
  6. Inline-code 列表 语义化 版本 的 三 触发:MAJOR(破坏性 API 变更)、MINOR(向后 兼容 新 特性)、PATCH(bug 修复)。按 3.6.2 同 PR 落 版本 bump 与 CHANGELOG.md 条目。
  7. 上面 的 练习 加 两 个 渐进式 Hint。

中国 区 锚点

本课 的 中文 示例 以 流动 性 较好 的 宽 基 ETF 与 国内 量化 业务 现场 为 锚:3.6.4 capstone 的 Kafka topic 按 沪深300 ETF (沪市 510300 / 510050 是 经典 标的) 的 tick 流 keyed by 合约 代码 走,锁 定 文件 纪律 是 上证 / 深证 监管 体系 下 私募 与 公募 通用 的 modal 答案,结构化 JSON 日志 行 喂给 的 dashboard 里 既 看 沪深300 也 看 CFFEX 的 IF / IC / IH 合约 行情。feed-handler 服务 拉 的 是 合成 的 沪深300 形 流——绝不 走 真实 单 股 数据——但 同 套 打包 纪律 适 用 于 上交所 SSE、深交所 SZSE、CFFEX 等 任何 一 个 国内 交易所。wheel 文件 名 feed_handler-1.0.0-py3-none-any.whl 不 因 topic 是 ticks.sse.510300 还是 ticks.cffex.if2503 而 变;打包 是 代码 的 属性,不 是 数据 的 属性。T+1 结算 与 涨跌停 等 国内 微 结构 也 不 影响 打包 形态。

阅读 清单

Python 官方 中文 打包 用户 指南 packaging.python.org/zh-cn/;PEP 517 / 518 / 621 / 440 / 508 社区 翻译;uv 文档 docs.astral.sh/uv/ 英文 权威;Poetry 中文 文档 python-poetry.org/docs/;阿里云 Codeup 制品 仓库 文档;腾讯云 CODING 制品 仓库 文档;中文 社区 维护 的 Python 打包 入门 教程 与 各 公有云 厂商 的 制品 仓库 帮助 中心。一条 额外 注释:国内 量化 firm 的 内部 wheel 通常 由 数据 / 平台 团队 统一 镜像 与 索引,quant developer 仍 需 在 自己 项目 内 维护 pyproject.tomluv.lock,跑 python -m build 产 出 wheel 交付 给 部署 团队。私募 的 工程 文化 在 这 一 步 上 与 公募 / 券商 自营 几乎 一致:pyproject.toml 是 唯一 真理 源、wheel 是 工件、锁 定 文件 是 印章。

通往 L2 的桥

下一课 把 这份 wheel 和这份 锁定文件 通过 多阶段 Containerfile 烤进 OCI 镜像,并应用每次 代码 评审 都会 抓 的 非 root 用户、镜像 扫描、标签 纪律。