通常开发一个 SDK(aioquant)需要实时在其应用项目(quant-dev)进行调试,通过可编辑安装(editable install)打通联动开发。本文记录真实的目录结构、打包配置以及"改 SDK → 应用仓跑测试"的日常工作流。
1. 为什么要拆 SDK
量化交易中事件循环、行情接入、订单管理这些框架代码,和具体的交易策略混在一起,问题很快会显现:
- • 策略仓越来越重:改一行策略要在一堆框架文件里找位置,框架升级时策略仓到处冲突;
- • 复用为零:想再起一个策略项目,只能复制整个仓库;
- • 职责不清:框架的 bug 和策略的 bug 混在同一个提交历史里,排查困难。
解法就是把"框架"和"应用"拆成两个仓库:
- • aioquant:异步事件驱动的量化交易框架(SDK)。负责事件总线、行情/交易统一接口、回测、持久化、风控,不含任何具体策略;
- • quant-dev:应用项目:策略代码、运行配置、入口脚本,只写策略相关业务。
两个仓库是同级目录摆放,这是后面可编辑安装联动的前提:
PycharmProjects/
├── aioquant/ # SDK :框架本体
└── quant-dev/ # 应用:策略 + 配置
2. aioquant 与 quant-dev 的文件目录
2.1 SDK:aioquant
aioquant/
├── pyproject.toml # 打包与元数据配置
├── __init__.py # 包入口:__version__ + 顶层便捷导出
├── quant.py # AIOQuant 主类:框架启动入口
├── configure.py # config 解析(json 配置 → config 对象)
├── const.py # 常量与枚举定义
├── error.py # 错误码体系
├── event.py # EventCenter:统一事件总线
├── market.py # Market / Orderbook / Kline / Ticker 行情模块
├── trade.py # Trade 统一交易接口
├── order.py # Order 订单对象
├── asset.py # Asset 资产查询
├── position.py # Position 持仓对象
├── risk.py # RiskManager 风控
├── decision.py # Decision 决策溯源记录
├── backtest.py # 回测引擎(MockTrade)
├── data.py # 数据访问层(DolphinDB/MongoDB/Pickles 后端路由)
├── symbol.py # 交易对符号处理
├── heartbeat.py # 心跳
├── tasks.py # 异步任务封装
├── exchange/ # 交易所适配层(子包)
│ ├── __init__.py
│ ├── binance.py # 币安
│ ├── okx.py # 欧易
│ ├── bitmex.py # BitMEX
│ └── mock.py # 回测模拟交易所
└── utils/ # 工具集(子包)
├── __init__.py
├── logger.py # 日志
├── decorator.py # 装饰器(并发安全等)
├── mongo.py # MongoDB 客户端封装
├── pickles.py # Pickles 序列化存储
├── web.py # HTTP 工具
├── tools.py # 通用工具函数
└── pid.py # 进程 PID 管理
aioquant 采用的是非标准布局:当前目录本身就是aioquant 包的内容,而不是常见的 src/aioquant/ 的结构。模块即文件(market.py、trade.py),按领域聚合出 exchange/、utils/ 两个子包。这个布局选择是后文踩坑的直接原因,先记住这一点。
2.2 应用:quant-dev
quant-dev/
├── requirements.txt # 依赖清单:-e ../aioquant + 锁定的间接依赖
├── pyproject.toml # 工具链配置
├── main.py # 默认入口(config.json + my_strategy)
├── main_market_server.py # 行情服务入口
├── config.json # 运行配置(日志级别、心跳等)
├── config_market.json # 行情服务配置
├── strategy/ # 业务策略(应用仓的核心产出)
│ ├── my_strategy.py
│ └── market.py
├── examples/ # SDK 测试
│ ├── test_event_unsubscribe.py
│ ├── test_binance_orderbook_nosync.py
│ ├── test_okx_orderbook_nosync.py
│ ├── test_bitmex_id_map.py
│ ├── test_release_orderbook.py
│ ├── test_orderbook_create_many.py
│ ├── test_orderbook_sampler.py
│ ├── test_data_backend_priority.py
│ ├── test_pickles_arrow.py
│ ├── test_pickles_backpressure.py
│ └── test_heartbeat_py314.py
├── docs/ # 使用文档:策略设计书、回测指南、各交易所接入指南
└── logs/ # 运行日志
对比可以看出分工:aioquant 回答"框架怎么工作",quant-dev 回答"我用框架做什么"。
3. SDK 仓库工程化:pyproject.toml
SDK 的核心配置就一份 pyproject.toml(aioquant 的真实内容,注释保留原文):
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "aioquant"
dynamic = ["version"]
description = "Asynchronous event I/O driven quantitative trading framework"
requires-python = ">=3.10"
dependencies = [
"aiohttp>=3.9",
"aio_pika>=9.0",
"motor>=3.3",
]
[project.optional-dependencies]
dev = ["pytest", "pytest-asyncio", "ruff"]
[tool.setuptools.dynamic]
version = {attr = "aioquant.__version__"}
[tool.setuptools.packages.find]
# 当前目录本身即为 `aioquant` 包内容,把搜索根放到父目录,
# 让 setuptools 在父目录下发现 `aioquant/` 包并正确生成 editable MAPPING。
# 用 where = ["."] 会找不到任何包(当前目录本身不会被视为顶层包)。
where = [".."]
include = ["aioquant*"]
exclude = ["examples*", "docs*"]
几个关键决策:
- •
dynamic = ["version"]:版本号不写死在 pyproject 里,而是从 aioquant/__init__.py 的 __version__ 动态读取(单一事实来源,import aioquant; aioquant.__version__ 与安装元数据永远一致); - •
dependencies 只放框架运行的依赖库:aiohttp、aio_pika、motor。 - •
optional-dependencies.dev:pytest、ruff 等开发工具与运行依赖分离,pip install -e ".[dev]" 一步装齐; - •
packages.find 的 where = [".."]:这是被坑出来的写法,见下文。
pip install -e . 显示成功,setuptools 却一个包都没找到
现场:在 aioquant 仓库根执行 pip install -e .,命令正常结束,但安装的包里没有任何模块——import aioquant 直接 ModuleNotFoundError。
原因:setuptools 的 packages.find 默认 where = ["."],即在当前目录下寻找子目录形式的包。而 aioquant 是非标准布局——当前目录本身就是包内容,当前目录不会被视作顶层包,于是一个包都发现不了。安装"成功"只是装了个空壳元数据。
解法:把搜索根挪到父目录 where = [".."],让 setuptools 站在父目录往下看,aioquant/ 就是一个正常的顶层包了。标准布局(src/ 或同名子目录)不会有这个问题,但如果你的 SDK 也是"目录即包"的形态,这一行就是关键。
4. 可编辑安装打通双仓
4.1 应用的依赖清单
quant-dev 的 requirements.txt:
# SDK:可编辑安装指向同级源码仓库
-e ../aioquant
# 显式锁定的间接依赖(让重建环境可复现)
aiohttp>=3.9
aio_pika>=9.0
motor>=3.3
# 测试依赖
pytest>=8.0
pytest-asyncio>=0.23
两个细节:
- •
-e ../aioquant:以可编辑(editable)模式安装本地 SDK。pip 不会拷贝源码到 site-packages,而是注册一个指向 ../aioquant 的映射——你改 SDK 源码,应用仓立即生效,无需重装; - • 显式列出间接依赖:aiohttp 这些本该由 aioquant 自动带入,这里显式写出来的目的是锁定环境可复现。重建虚拟环境时,即使 SDK 的依赖声明将来变化,应用仓的环境基线依然确定。
4.2 可编辑安装的原理
pip install -e 会在 site-packages 里放一个 __editable__.aioquant.*.finder 模块和对应的 .pth 文件,把 import aioquant 的查找请求重定向到你本地的源码目录。所以它不是软链接,也不是拷贝,而是一张"包名 → 本地路径"的映射表(MAPPING),映射表里漏了哪个子包,哪个子模块就导入失败。
import aioquant 正常,子模块 aioquant.exchange.binance 导入失败
现场:import aioquant 成功了,但应用代码里 from aioquant.exchange.binance import ... 报 ModuleNotFoundError(顶层能导入、子包导入失败)。
排查路径:
- 1.
pip show -f aioquant——查看实际安装进来的文件列表。如果列表里只有顶层 .py 文件、没有 exchange/、utils/ 子包目录,说明打包发现阶段就漏了子包; - 2.
python -c "import aioquant; print(aioquant.__path__)"——看包的搜索路径指向哪里。指向本地源码目录说明 editable 生效,指向 site-packages 里的拷贝则说明存在旧的普通安装残留; - 3. 检查
packages.find 的 include 模式——include = ["aioquant*"] 能同时匹配 aioquant 与 aioquant.exchange、aioquant.utils;如果当初只手写了 packages = ["aioquant"],子包就不会被发现。
根因:editable 映射表(MAPPING)生成时子包没被包含——要么 where 搜索根不对导致只发现了部分包,要么 include 模式没覆盖子包,要么是修复配置前的一次旧安装残留(普通安装的目录优先于 editable 映射被找到)。
解法:确认 where = [".."] + include = ["aioquant*"] 后,先卸载再重装:
pip uninstall aioquant -y && pip install -e ../aioquant
editable 安装的配置变更不会自动刷新,改了 pyproject 必须重装一次。
4.3 验证联动
装好之后做一个最简单的联动验证——在 quant-dev 里跑一个只用 SDK 的入口。main.py 全文:
from aioquant import quant
def strategy():
print("I'm here ...")
from strategy.my_strategy import MyStrategy
MyStrategy()
if __name__ == "__main__":
config_file = "config.json"
quant.start(config_file, strategy)
然后随便在 aioquant 源码里加一行 print(比如 __init__.py),再运行 python main.py config.json——无需任何安装动作,输出立刻出现。联动打通的标志就是:SDK 的源码目录 = 应用运行时加载的代码。
5. 日常联动开发工作流
双仓结构的日常节奏是一个闭环:
在 aioquant 改框架代码
│
▼
quant-dev 无需重装,直接跑(行为已变)
│
▼
在 quant-dev/examples 跑测试,验证 SDK 改动没有破坏应用
│
▼
两边分别 git commit
6. 版本与发布
6.1 现状:动态版本
aioquant 目前用动态版本,aioquant/__init__.py:
__version__ = "5.1.2"
__version_info__ = (5, 1, 2)
配合 pyproject 的 version = {attr = "aioquant.__version__"},版本号的唯一事实来源就是这行代码,版本号只在元数据层面有意义。