第 5 章 · 02 BaseApp 与动态加载的数据库/数据服务


文档摘要

第 5 章 · 02 BaseApp 与动态加载的数据库/数据服务 本节摘要:本节精读 VeighNa 的三个轻量抽象—— (22 行)、 (159 行)、 (68 行)。BaseApp 是所有业务模块(ctastrategy/spreadtrading/optionmaster 等)的契约,全篇 22 行没有一个 @abstractmethod、没有 init、没有 install(),纯靠类属性注解就把接口说清楚,是 VeighNa 最优雅的"类型即契约"范例。BaseDatabase 与 BaseDatafeed 则展示了另一种装配哲学——和网关/应用靠用户手动 add 不同,数据库/数据服务靠配置项自动 importmodule 发现,核心包仍然零硬依赖。

第 5 章 · 02 BaseApp 与动态加载的数据库/数据服务

本节摘要:本节精读 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 行),精读并套用体系化模板。

学习目标

阅读完本节,你应当能够:

  1. 说清 BaseApp 为什么这么短(22 行)——纯类型注解式契约的设计意图。
  2. 列举 BaseApp 的 7 个类属性,知道它们各自的用途(app_name/app_module/app_path/display_name/engine_class/widget_name/icon_name)。
  3. 区分 BaseDatabase(ABC,8 个抽象方法)BaseDatafeed(普通类,默认失败实现)
  4. 逐行读懂 get_database() / get_datafeed() 两个单例工厂的兜底逻辑。
  5. 对比"网关/应用靠手动装配"与"数据库/数据服务靠 import_module 自动发现"两种装配哲学。

一、BaseApp:22 行的契约

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

注意几个关键事实:

  • 继承 ABC,但没有一个 @abstractmethod。这意味着 BaseApp 实例化不会因为"未实现抽象方法"报错——它只是被 ABC 标记"我是抽象类,请用 isinstance 判断时把我当基类"。
  • 没有 __init__ 方法。子类自己决定构造签名(MainEngine.add_app 时通常不直接实例化 app,而是读取它的类属性)。
  • 没有 install() / start() 等生命周期方法。BaseApp 不规定 app 怎么运行——运行逻辑全在 engine_class 指向的具体引擎类里(cta_strategy 引擎、spread_trading 引擎各自实现)。
  • 7 个属性全是纯类型注解,没有默认值。子类必须填全这 7 个属性才能正常工作(运行时缺属性会 AttributeError)。

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 识别"的作用,不是约束手段

二、BaseDatabase:8 个抽象方法

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_TZconvert_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 有多少条、时间范围。BarOverviewTickOverview 多一个 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_sqlitevnpy_mysqlvnpy_postgresqlvnpy_mongodbvnpy_influxdbvnpy_arcticvnpy_dolphindbvnpy_tdengine 等驱动都实现这 8 个方法。注意 save_*stream: bool = False 参数,这是后来加的流式写入能力(高频 tick 直接入库不缓存),旧驱动可以忽略。

三、get_database 单例工厂

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

逐段拆解:

  1. 模块级全局变量 database(L136):懒加载单例缓存,首次调用时填,之后直接返回——避免每次读写都重复 import_module。
  2. 读配置(L148):从全局 SETTINGS 取 database.name,比如 "mysql" / "sqlite" / "influxdb"
  3. 拼模块名(L149):f"vnpy_{database_name}"——这是核心包和外部驱动的命名约定。所有数据库驱动必须叫 vnpy_<小写名>
  4. try import_module(L153-157):运行时动态导入。没装这个驱动ModuleNotFoundError,兜底 import vnpy_sqlite(SQLite 驱动几乎一定装了,因为是默认依赖)。
  5. 取 Database 类并实例化(L159):约定——每个驱动模块顶层暴露一个 Database 类。

💡 核心心法:这套"配置 → 拼模块名 → import_module → 取约定类"是 VeighNa 装配"隐式插件"的标准套路。和网关那种"用户 import + add_gateway"的显式装配完全不同——数据库对用户透明:用户只在配置文件改一行 database.name = mysql,运行时框架就自动接上了 MySQL 驱动。这种零代码装配的代价是命名约定必须严守(包名 vnpy_xxx + 类名 Database),否则 import 得到但取不出类。

四、BaseDatafeed:不是 ABC,默认实现失败

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):

  1. 没有继承 ABC——class BaseDatafeed: 直接定义,不带括号。
  2. 没有 @abstractmethod——三个方法都有默认实现。
  3. 默认实现就是"失败":init 返回 False,两个查询返回空列表并 output 打印提示。
  4. 方法签名带 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 抽象? 因为业务约束不同:

  • 数据库是必需的——策略启动要 load 历史数据,没数据库就跑不起来,所以必须强制每个驱动实现完整方法(用 ABC + abstractmethod 保证)。
  • 数据服务是可选的——很多用户根本不下载历史数据(只用实盘 tick),没装数据服务插件也能正常跑 VeighNa。所以 BaseDatafeed 给"未配置"的默认实现:返回失败但不报错,让上层逻辑优雅降级。

output(_("查询K线数据失败:没有正确配置数据服务")) 这种设计很巧妙——把"失败信息"通过调用方传入的 output 回调显示(可能是 GUI 进度框、可能是 stdout),不强制走 logging,耦合更低。

五、get_datafeed 单例工厂

datafeed.py:36-68get_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

三种路径:

  1. 配置为空(L49-51):用户根本没设 datafeed.name,直接用 BaseDatafeed() 空实现 + 打印提示。这是"我不需要数据服务"的合法状态。
  2. 配置了但模块没装(L57-61):import_module 抛 ModuleNotFoundError,也退回 BaseDatafeed(),但提示用户"pip install vnpy_xxx"。
  3. 配置且装了(L58-59):import 成功,实例化 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 数据服务的关键。

本节要点回顾

  1. BaseApp 22 行:纯类型注解式契约,7 个类属性(app_name/app_module/app_path/display_name/engine_class/widget_name/icon_name),无 @abstractmethod、无 init、无 install()。子类填属性即接入。
  2. BaseApp 继承 ABC 只为标记:与 BaseGateway 的 7 个抽象方法对比,app 类本身是配置壳,工作在 engine_class 里。
  3. BaseDatabase 是 ABC,8 个抽象方法(4 组 CRUD:存/读/删/概览),有时区处理(DB_TZ/convert_tz)和 Overview 数据类。
  4. BaseDatafeed 不是 ABC:三个方法默认实现返回失败(init→False、查询→空列表 + output 提示),因为数据服务是可选的。
  5. 两个单例工厂:get_database() / get_datafeed() 都是"模块级全局变量缓存 + import_module(f'vnpy_{name}') + 取约定类"。数据库兜底 vnpy_sqlite,数据服务兜底空实现——反映"必需 vs 可选"的差异。
  6. 命名约定:数据库暴露 Database 类,数据服务暴露 Datafeed 类;包名一律 vnpy_<name>
  7. 两类装配哲学:网关/应用靠用户手动 add_*(多实例、自由组合);数据库/数据服务靠配置自动发现(单例、配置驱动)。各取所长,不一刀切。

下一节(第 6 章),我们从抽象层下沉到数据层——精读 vnpy/trader/object.py 里 TickData/OrderData/TradeData/PositionData/AccountData/ContractData 等核心数据类,看 VeighNa 怎么用 dataclass + vt_symbol/vt_orderid 等"虚拟标识"统一表达全球 30+ 个交易所的业务对象。


发布者: 作者: 灏天文库 转发
评论区 (0)
U