第 5 章 · 02 BaseApp 与动态加载的数据库/数据服务 本节摘要:本节精读 VeighNa 的三个轻量抽象—— (22 行)、 (159 行)、 (68 行)。BaseApp 是所有业务模块(ctastrategy/spreadtrading/optionmaster 等)的契约,全篇 22 行没有一个 @abstractmethod、没有 init、没有 install(),纯靠类属性注解就把接口说清楚,是 VeighNa 最优雅的"类型即契约"范例。BaseDatabase 与 BaseDatafeed 则展示了另一种装配哲学——和网关/应用靠用户手动 add 不同,数据库/数据服务靠配置项自动 importmodule 发现,核心包仍然零硬依赖。
本节摘要:本节精读 VeighNa 的三个轻量抽象——
vnpy/trader/app.py(22 行)、database.py(159 行)、datafeed.py(68 行)。BaseApp 是所有业务模块(cta_strategy/spread_trading/option_master 等)的契约,全篇 22 行没有一个 @abstractmethod、没有 init、没有 install(),纯靠类属性注解就把接口说清楚,是 VeighNa 最优雅的"类型即契约"范例。BaseDatabase 与 BaseDatafeed 则展示了另一种装配哲学——和网关/应用靠用户手动 add_* 不同,数据库/数据服务靠配置项自动 import_module 发现,核心包仍然零硬依赖。读完本节,你彻底看清 VeighNa 的两类插件边界:手动装配的"显式"插件 vs. 自动发现的"隐式"插件。
内容来源:原项目源码
vnpy/trader/app.py(22 行)、vnpy/trader/database.py(159 行)、vnpy/trader/datafeed.py(68 行),精读并套用体系化模板。
阅读完本节,你应当能够:
get_database() / get_datafeed() 两个单例工厂的兜底逻辑。app.py 全文只有 22 行,贴出来感受一下(app.py:1-22):
1 from abc import ABC 2 from pathlib import Path 3 from typing import TYPE_CHECKING 4 5 6 if TYPE_CHECKING: 7 from .engine import BaseEngine 8 10 class BaseApp(ABC): 11 """ 12 Abstract class for app. 13 """ 15 app_name: str # Unique name used for creating engine and widget 16 app_module: str # App module string used in import_module 17 app_path: Path # Absolute path of app folder 18 display_name: str # Name for display on the menu. 19 engine_class: type["BaseEngine"] # App engine class 20 widget_name: str # Class name of app widget 21 icon_name: str # Icon file name of app widget
注意几个关键事实:
@abstractmethod。这意味着 BaseApp 实例化不会因为"未实现抽象方法"报错——它只是被 ABC 标记"我是抽象类,请用 isinstance 判断时把我当基类"。__init__ 方法。子类自己决定构造签名(MainEngine.add_app 时通常不直接实例化 app,而是读取它的类属性)。install() / start() 等生命周期方法。BaseApp 不规定 app 怎么运行——运行逻辑全在 engine_class 指向的具体引擎类里(cta_strategy 引擎、spread_trading 引擎各自实现)。7 个类属性各司其职:
| 属性 | 类型 | 用途 |
|---|---|---|
app_name |
str | 唯一名,用于注册引擎和 widget 的 key(如 "cta_strategy") |
app_module |
str | import_module 时用的模块字符串(如 "vnpy_ctastrategy") |
app_path |
Path | app 文件夹的绝对路径,用于找 icon、找子模块 |
display_name |
str | UI 菜单上显示的名字(如 "CTA 策略") |
engine_class |
type[BaseEngine] | 真正干活的引擎类(策略调度、订单管理都在引擎里) |
widget_name |
str | app 的 Qt widget 类名(UI 面板) |
icon_name |
str | 图标文件名(UI 显示用) |
注意 app_module ——这是给 MainEngine 内部动态加载用的。第 1 章 02 节讲过 import_module(f"vnpy_{name}") 的魔法,这里 app_module 就是那个完整模块字符串。
💡 核心心法:BaseApp 是"类型即契约"的典范。它不强制子类实现任何方法,只规定"你要挂这 7 个静态属性"。这种设计的好处是:子类就是个配置描述符,MainEngine 读它的属性就知道该 import 什么模块、实例化什么引擎、显示什么菜单——零代码逻辑耦合。VeighNa 的 17 个 app(cta_strategy/cta_backtester/spread_trading/option_master/portfolio_strategy/script_trader/...)全都靠填这 7 个属性接入主框架。
⚠️ 重要说明:BaseApp 继承 ABC 但没有
@abstractmethod,与 BaseGateway 的"7 个抽象方法"形成鲜明对比。区别在于:网关必须强制子类实现 connect/send_order 等(否则没法用),而 app 的"工作"全在 engine_class 里——app 类本身只是配置壳,自然不需要强制方法。ABC 在这里只起"标记 + isinstance 识别"的作用,不是约束手段。
database.py 比 app.py 实质多了。先看时区基础设施(database.py:14-22):
14 DB_TZ = ZoneInfo(SETTINGS["database.timezone"]) 15 17 def convert_tz(dt: datetime) -> datetime: 18 """ 19 Convert timezone of datetime object to DB_TZ. 19 """ 20 dt = dt.astimezone(DB_TZ) 21 return dt.replace(tzinfo=None)
模块加载时就读配置 database.timezone(默认 "Asia/Shanghai")生成 DB_TZ。convert_tz 把任意 datetime 转成 DB_TZ 并去掉 tzinfo(裸 datetime),这是为了适配 SQLite/MySQL 不支持原生时区的存储习惯。所有驱动存数据前都调它统一时区。
两个数据类(database.py:25-49):
25 @dataclass 26 class BarOverview: 31 symbol: str = "" 32 exchange: Exchange | None = None 33 interval: Interval | None = None 34 count: int = 0 35 start: datetime | None = None 36 end: datetime | None = None 39 @dataclass 40 class TickOverview: 45 symbol: str = "" 46 exchange: Exchange | None = None 47 count: int = 0 48 start: datetime | None = None 49 end: datetime | None = None
Overview 是"概览"——数据库里某个合约的 K 线/Tick 有多少条、时间范围。BarOverview 比 TickOverview 多一个 interval(分钟/小时/日 K),因为同一合约可以有多个周期的 K 线;tick 只有一种粒度。
然后是 BaseDatabase 本体(database.py:52-133),8 个全是 @abstractmethod:
| 方法 | 行号 | 返回 | 用途 |
|---|---|---|---|
save_bar_data(bars, stream) |
57-62 | bool | 批量存 K 线(stream 流式写) |
save_tick_data(ticks, stream) |
64-69 | bool | 批量存 Tick |
load_bar_data(symbol, exchange, interval, start, end) |
71-83 | list[BarData] | 按区间载入 K 线 |
load_tick_data(symbol, exchange, start, end) |
85-96 | list[TickData] | 按区间载入 Tick |
delete_bar_data(symbol, exchange, interval) |
98-108 | int | 删 K 线,返回删除条数 |
delete_tick_data(symbol, exchange) |
110-119 | int | 删 Tick,返回删除条数 |
get_bar_overview() |
121-126 | list[BarOverview] | 列出所有 K 线数据的概览 |
get_tick_overview() |
128-133 | list[TickOverview] | 列出所有 Tick 数据的概览 |
4 组对称的 CRUD:存/读/删 + 概览。vnpy_sqlite、vnpy_mysql、vnpy_postgresql、vnpy_mongodb、vnpy_influxdb、vnpy_arctic、vnpy_dolphindb、vnpy_tdengine 等驱动都实现这 8 个方法。注意 save_* 有 stream: bool = False 参数,这是后来加的流式写入能力(高频 tick 直接入库不缓存),旧驱动可以忽略。
database.py:136-159 是核心包装配数据库的入口:
136 database: BaseDatabase | None = None 137 139 def get_database() -> BaseDatabase: 141 # Return database object if already inited 142 global database 143 if database: 144 return database 145 147 # Read database related global setting 148 database_name: str = SETTINGS["database.name"] 149 module_name: str = f"vnpy_{database_name}" 150 152 # Try to import database module 153 try: 154 module: ModuleType = import_module(module_name) 155 except ModuleNotFoundError: 156 print(_("找不到数据库驱动{},使用默认的SQLite数据库").format(module_name)) 157 module = import_module("vnpy_sqlite") 158 159 database = module.Database() 160 return database
逐段拆解:
database(L136):懒加载单例缓存,首次调用时填,之后直接返回——避免每次读写都重复 import_module。database.name,比如 "mysql" / "sqlite" / "influxdb"。f"vnpy_{database_name}"——这是核心包和外部驱动的命名约定。所有数据库驱动必须叫 vnpy_<小写名>。ModuleNotFoundError,兜底 import vnpy_sqlite(SQLite 驱动几乎一定装了,因为是默认依赖)。Database 类。💡 核心心法:这套"配置 → 拼模块名 → import_module → 取约定类"是 VeighNa 装配"隐式插件"的标准套路。和网关那种"用户 import + add_gateway"的显式装配完全不同——数据库对用户透明:用户只在配置文件改一行
database.name = mysql,运行时框架就自动接上了 MySQL 驱动。这种零代码装配的代价是命名约定必须严守(包名vnpy_xxx+ 类名Database),否则 import 得到但取不出类。
datafeed.py 与 database.py 的设计故意不同。先看基类(datafeed.py:10-33):
10 class BaseDatafeed: 11 """ 12 Abstract data feed class for connecting to different data feed. 12 """ 15 def init(self, output: Callable = print) -> bool: 19 return False 21 def query_bar_history(self, req: HistoryRequest, output: Callable = print) -> list[BarData]: 25 output(_("查询K线数据失败:没有正确配置数据服务")) 26 return [] 28 def query_tick_history(self, req: HistoryRequest, output: Callable = print) -> list[TickData]: 32 output(_("查询Tick数据失败:没有正确配置数据服务")) 33 return []
四个关键差异(对比 BaseDatabase):
class BaseDatafeed: 直接定义,不带括号。init 返回 False,两个查询返回空列表并 output 打印提示。output: Callable = print——把日志输出回调暴露给调用方(通常 UI 进度条)。三个方法对应数据服务的全部能力:
| 方法 | 返回 | 用途 |
|---|---|---|
init(output) |
bool | 初始化连接(登录数据服务) |
query_bar_history(req, output) |
list[BarData] | 拉历史 K 线 |
query_tick_history(req, output) |
list[TickData] | 拉历史 Tick |
为什么 BaseDatafeed 不抽象、BaseDatabase 抽象? 因为业务约束不同:
output(_("查询K线数据失败:没有正确配置数据服务")) 这种设计很巧妙——把"失败信息"通过调用方传入的 output 回调显示(可能是 GUI 进度框、可能是 stdout),不强制走 logging,耦合更低。
datafeed.py:36-68 与 get_database() 同构,但兜底逻辑更细:
36 datafeed: BaseDatafeed | None = None 37 39 def get_datafeed() -> BaseDatafeed: 42 global datafeed 43 if datafeed: 44 return datafeed 45 47 datafeed_name: str = SETTINGS["datafeed.name"] 48 49 if not datafeed_name: 50 datafeed = BaseDatafeed() 51 print(_("没有配置要使用的数据服务,请修改全局配置中的datafeed相关内容")) 52 else: 53 module_name: str = f"vnpy_{datafeed_name}" 55 57 try: 58 module: ModuleType = import_module(module_name) 59 datafeed = module.Datafeed() 60 except ModuleNotFoundError: 60 datafeed = BaseDatafeed() 61 print(_("无法加载数据服务模块,请运行 pip install {} 尝试安装").format(module_name)) 62 63 return datafeed
三种路径:
datafeed.name,直接用 BaseDatafeed() 空实现 + 打印提示。这是"我不需要数据服务"的合法状态。BaseDatafeed(),但提示用户"pip install vnpy_xxx"。module.Datafeed()。注意第三个命名差异——数据库驱动暴露 Database 类,数据服务驱动暴露 Datafeed 类。首字母大写但同名后缀(模块名小写)。这是核心包和外部驱动的第二个约定。
⚠️ 澄清一个易错点:有人觉得"
get_datafeed找不到模块也返回 BaseDatafeed,get_database找不到模块兜底vnpy_sqlite——为什么处理方式不一样?"因为数据库必须有(框架启动刚需),所以兜底到"几乎一定装"的 sqlite;数据服务可以没有(很多用户用不上),所以兜底到"空实现"即可。两种兜底反映了两类插件的"必要性差异"。
把第 01 节的网关/App 和本节的数据库/数据服务放一起对比:
| 维度 | 网关 / 应用 | 数据库 / 数据服务 |
|---|---|---|
| 装配方式 | 用户手动 add_gateway(GatewayClass) / add_app(AppClass) |
框架自动 import_module 发现 |
| 触发时机 | run.py 里显式调用 | 第一次访问 get_database() / get_datafeed() 时懒加载 |
| 配置来源 | 用户的 run.py 代码 | 全局配置文件 SETTINGS["xxx.name"] |
| 能否多实例 | 可以(同时装 CTP + IB,多个 cta 应用) | 不可以(单例,模块级 database / datafeed 变量) |
| 命名约定 | 类名自由(CtpGateway/CtaStrategyApp) | 包名严格 vnpy_<name> + 类名严格 Database/Datafeed |
| 基类风格 | ABC + @abstractmethod 强约束 | BaseDatabase 用 ABC,BaseDatafeed 用普通类 |
为什么网关/应用要手动装配?因为同一框架可能同时挂多个网关、多个应用——你既要做 CTP 期货、又要做 IB 美股,既要做 CTA、又要做价差。靠单一配置项 gateway.name = ctp 表达不出"多实例 + 自由组合",所以交给用户在 run.py 里 add_gateway(CtpGateway); add_gateway(IbGateway) 显式装配。
为什么数据库/数据服务用自动发现?因为这两类天然是单例——一个程序只用一个数据库、一个数据源。配置项一行字符串就够,没必要让用户写代码装配。
💡 核心心法:这是"显式 vs. 隐式"两种插件哲学的分工。多实例、强组合性的插件用显式装配(用户掌握控制权);单例、配置驱动的插件用隐式发现(用户体验更顺)。VeighNa 同时用了两套,各取所长——这种"不一刀切"的工程判断,正是框架能同时撑起 30+ 网关 + 17 应用 + 8 数据库 + 9 数据服务的关键。
engine_class 里。init→False、查询→空列表 + output 提示),因为数据服务是可选的。get_database() / get_datafeed() 都是"模块级全局变量缓存 + import_module(f'vnpy_{name}') + 取约定类"。数据库兜底 vnpy_sqlite,数据服务兜底空实现——反映"必需 vs 可选"的差异。Database 类,数据服务暴露 Datafeed 类;包名一律 vnpy_<name>。下一节(第 6 章),我们从抽象层下沉到数据层——精读
vnpy/trader/object.py里 TickData/OrderData/TradeData/PositionData/AccountData/ContractData 等核心数据类,看 VeighNa 怎么用 dataclass + vt_symbol/vt_orderid 等"虚拟标识"统一表达全球 30+ 个交易所的业务对象。