第 3 章 · 02 object.py 的交易与账户数据类


文档摘要

第 3 章 · 02 object.py 的交易与账户数据类 本节摘要:本节精读 的交易与账户侧数据类——OrderData/TradeData/PositionData/AccountData/ContractData/LogData,以及一组 Request 请求类。这一组类是 VeighNa "订单生命周期"的数据骨架:OrderData 跟踪委托状态、TradeData 记录每次成交、PositionData 持仓按多空方向分别建表、AccountData 派生 available 字段、ContractData 描述合约规格。

第 3 章 · 02 object.py 的交易与账户数据类

本节摘要:本节精读 vnpy/trader/object.py 的交易与账户侧数据类——OrderData/TradeData/PositionData/AccountData/ContractData/LogData,以及一组 Request 请求类。这一组类是 VeighNa "订单生命周期"的数据骨架:OrderData 跟踪委托状态、TradeData 记录每次成交、PositionData 持仓按多空方向分别建表、AccountData 派生 available 字段、ContractData 描述合约规格。每个数据类都遵循统一套路——__post_init__ 自动拼出 vt_ 前缀的全局唯一主键,其中 AccountData 和 PositionData 是最有意思的两个特例:AccountData 的 available 是 balance-frozen 算出来的派生字段,PositionData 的 vt_positionid 是 gateway.vt_symbol.direction 三元组。读完本节,你掌握了 VeighNa 从下单到持仓到资金的完整数据链路。

内容来源:原项目源码 vnpy/trader/object.py(L111-427),精读并套用体系化模板。

学习目标

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

  1. 说清 OrderData 的字段分组与它的两个 vt_ 键(vt_symbol、vt_orderid)。
  2. 解释 is_active() 为什么用 ACTIVE_STATUSES 集合判断,以及"活跃订单"包含哪三种状态。
  3. 区分 OrderData 与 TradeData 的 vt_ 键——为什么 TradeData 多出一个 vt_tradeid。
  4. 解释 PositionData 的 vt_positionid 为什么是三元组(gateway.vt_symbol.direction)。
  5. 指出 AccountData 的 available派生字段,说出它的计算公式与触发时机。
  6. 列举 ContractData 的字段分两组:通用属性与期权专属属性。
  7. 区分 Request 系列(SubscribeRequest/OrderRequest/CancelRequest/HistoryRequest/QuoteRequest)与 Data 系列——为什么 Request 不带 gateway_name。

一、OrderData:委托订单

object.py:111-150:

111 @dataclass 112 class OrderData(BaseData): 118 symbol: str 119 exchange: Exchange 120 orderid: str 121 122 type: OrderType = OrderType.LIMIT 123 direction: Direction | None = None 124 offset: Offset = Offset.NONE 125 price: float = 0 126 volume: float = 0 127 traded: float = 0 128 status: Status = Status.SUBMITTING 129 datetime: Datetime | None = None 130 reference: str = "" 131 132 def __post_init__(self) -> None: 134 self.vt_symbol: str = f"{self.symbol}.{self.exchange.value}" 135 self.vt_orderid: str = f"{self.gateway_name}.{self.orderid}" 136 137 def is_active(self) -> bool: 141 return self.status in ACTIVE_STATUSES 142 143 def create_cancel_request(self) -> "CancelRequest": 147 req: CancelRequest = CancelRequest( 148 orderid=self.orderid, symbol=self.symbol, exchange=self.exchange 149 ) 150 return req

字段分四组:

组别 字段 说明
标识 symbol / exchange / orderid 本地三件套,加上基类的 gateway_name 才能全局唯一
委托要素 type / direction / offset / price / volume 委托类型、方向、开平、价格、数量
成交进度 traded / status 已成交量与当前状态(决定是否还可改/撤)
元信息 datetime / reference 时间戳与备注(常填策略名)

reference 字段是个有意思的设计——它是一个自由字符串,用户层常填策略名(如 "双均线策略")。后续可以按 reference 在所有订单里筛出本策略的委托,做归因分析。

1.1 两个 vt_ 键

__post_init__ 生成两个全局键:

134 self.vt_symbol: str = f"{self.symbol}.{self.exchange.value}" # 全局合约键 135 self.vt_orderid: str = f"{self.gateway_name}.{self.orderid}" # 全局订单键
  • vt_symbol:和 TickData/BarData 一样的"全局合约主键",跨交易所唯一。
  • vt_orderid:gateway_name.orderid——因为不同网关可能有相同 orderid(本地自增 id),必须再拼一层 gateway_name 才全局唯一。

💡 核心心法:OrderData 比 TickData 多一个 vt_orderid,因为订单的本地 id(orderid)由网关自定,跨网关会冲突。VeighNa 的统一解法是"网关前缀 + 本地 id"。这个套路贯穿所有需要"跨网关唯一"的对象——TradeData 的 vt_tradeid、QuoteData 的 vt_quoteid 都是同一招。

1.2 is_active() 与 ACTIVE_STATUSES

object.py:137-141:

137 def is_active(self) -> bool: 141 return self.status in ACTIVE_STATUSES

回看上一节模块常量(object.py:14):

14 ACTIVE_STATUSES = set([Status.SUBMITTING, Status.NOTTRADED, Status.PARTTRADED])

"活跃订单"包含三种状态:提交中、未成交、部分成交——也就是还可能继续成交或被撤销的状态。一旦状态进入 ALLTRADED/CANCELLED/REJECTED(下一节 constant.py 详讲),订单就"终结"了,is_active 返回 False。

为什么用 set 不用 list?集合的 in 查询是 O(1),list 是 O(n)。OrderData.is_active() 在策略循环里被高频调用(每个 tick 都可能扫一遍活动订单),用 set 保证性能。

1.3 create_cancel_request() 便利方法

object.py:143-150:从 OrderData 直接构造一个 CancelRequest——把 orderid/symbol/exchange 三个本地字段打包,生成撤单请求。这是典型的"对象自服务"——上层不必手工 new CancelRequest 再挨个填字段,直接调 order.create_cancel_request() 即可。第 4 章 MainEngine 的 cancel_order 流程就会用到它。

二、TradeData:一笔成交

object.py:153-175:

153 @dataclass 154 class TradeData(BaseData): 160 symbol: str 161 exchange: Exchange 162 orderid: str 163 tradeid: str 164 direction: Direction | None = None 165 166 offset: Offset = Offset.NONE 167 price: float = 0 168 volume: float = 0 169 datetime: Datetime | None = None 170 171 def __post_init__(self) -> None: 173 self.vt_symbol: str = f"{self.symbol}.{self.exchange.value}" 174 self.vt_orderid: str = f"{self.gateway_name}.{self.orderid}" 175 self.vt_tradeid: str = f"{self.gateway_name}.{self.tradeid}"

类注释说得很清楚(L156-158):

Trade data contains information of a fill of an order. One order can have several trade fills.

一笔订单可以对应多笔成交。比如下 10 手限价单,撮合时可能分三次成交(3 手 + 4 手 + 3 手),那就是 1 个 OrderData、3 个 TradeData。所以 OrderData 的 traded 字段是这 3 笔 TradeData 的 volume 之和。

2.1 三个 vt_ 键

TradeData 比其他数据类多一个键——vt_tradeid:

173 self.vt_symbol = f"{self.symbol}.{self.exchange.value}" # 合约键 174 self.vt_orderid = f"{self.gateway_name}.{self.orderid}" # 回溯到订单的键 175 self.vt_tradeid = f"{self.gateway_name}.{self.tradeid}" # 自身唯一键
  • vt_symbol:合约键(同前)。
  • vt_orderid:回溯键——通过它能把成交回溯到所属订单。这是"一笔订单多笔成交"反向查的入口。
  • vt_tradeid:成交自身的全局唯一键。tradeid 是网关分配的本地成交号,跨网关会冲突,所以也拼 gateway_name。

💡 核心心法:TradeData 同时持有 vt_orderid 和 vt_tradeid,体现了它"既是订单的子记录,又是独立实体"的双重身份。VeighNa 的 OmsEngine(第 4 章)就是用 vt_orderid 把 TradeData 累加到 OrderData.traded 上,再用 vt_tradeid 防止同一笔成交被重复统计(幂等性)。

三、PositionData:持仓

object.py:178-197:

178 @dataclass 179 class PositionData(BaseData): 184 symbol: str 185 exchange: Exchange 186 direction: Direction 187 188 volume: float = 0 189 frozen: float = 0 190 price: float = 0 191 pnl: float = 0 192 yd_volume: float = 0 193 194 def __post_init__(self) -> None: 196 self.vt_symbol: str = f"{self.symbol}.{self.exchange.value}" 197 self.vt_positionid: str = f"{self.gateway_name}.{self.vt_symbol}.{self.direction.value}"

字段解读:

字段 含义
direction 持仓方向(LONG 多 / SHORT 空 / NET 净)
volume 总持仓量
frozen 冻结量(已挂单未成交的部分)
price 持仓均价
pnl 浮动盈亏
yd_volume 昨仓(昨日结算留存的仓位)

yd_volume(昨仓)是国内期货的硬需求——上期所采用"平今平昨"分别撮合的规则(下一节 Offset 详讲),持仓必须区分今天新开和昨天遗留。所以 PositionData 单独留字段标记昨仓数量。

3.1 vt_positionid 三元组

196 self.vt_symbol = f"{self.symbol}.{self.exchange.value}" 197 self.vt_positionid: str = f"{self.gateway_name}.{self.vt_symbol}.{self.direction.value}"

vt_positionid 是三元组:gateway_name.vt_symbol.direction,如 "CTP.rb2401.SHFE.LONG"

为什么要拼 direction?因为同一合约可以同时持有多头和空头双向仓位(国内期货允许"锁仓")。如果不拼 direction,rb2401 的多头和空头会被合并成一条记录,丢失方向信息。所以持仓的全局唯一键必须包含方向。

⚠️ 重要说明:某些网关(如股票接口)采用 NET 净持仓模式——多空相抵只保留净额,direction 取 Direction.NET。这时同一合约只有一条 PositionData。所以判断"是否有持仓"不能简单数记录条数,要看具体 direction。

四、AccountData:账户资金

object.py:200-215:

200 @dataclass 201 class AccountData(BaseData): 207 accountid: str 208 209 balance: float = 0 210 frozen: float = 0 211 212 def __post_init__(self) -> None: 214 self.available: float = self.balance - self.frozen 215 self.vt_accountid: str = f"{self.gateway_name}.{self.accountid}"

字段极简——只有 accountid / balance / frozen 三个输入字段,然后 __post_init__ 派生出 available 和 vt_accountid。

4.1 available 是派生字段

214 self.available: float = self.balance - self.frozen # 派生字段 215 self.vt_accountid: str = f"{self.gateway_name}.{self.accountid}"

available(可用资金)不是用户传入的,而是在 __post_init__ 里现算的:balance - frozen

  • balance:账户总资金(含已冻结)。
  • frozen:已挂单/保证金占用等冻结金额。
  • available:可用余额,下单风控的核心指标——后续下单前判断"available 是否足够支付新单保证金"就靠它。

💡 核心心法:为什么不让用户直接传 available?因为它是计算属性,如果允许传入就可能和 balance/frozen 不一致(数据腐败)。VeighNa 选择"只让用户填两个根字段,派生字段框架自动算",保证一致性。这是"单一数据源"(Single Source of Truth)的工程实践——派生字段永远从根字段算出来,绝不独立存储。

注意 AccountData 没有 symbol/exchange——因为账户是按资金账户维度,与具体合约无关。它的全局键是 vt_accountid = gateway.accountid,如 "CTP.000001"

五、LogData:日志

object.py:218-229:

218 @dataclass 219 class LogData(BaseData): 224 msg: str 225 level: int = INFO 226 227 def __post_init__(self) -> None: 229 self.time: Datetime = Datetime.now()

LogData 有两个"派生"特征:

  1. time 是派生字段:__post_init__Datetime.now() 自动盖时间戳——用户构造日志时不必传时间,框架自动取当前时刻。这与 AccountData 的 available 同思路:能从"客观环境"算出的字段就不让用户填。
  2. level 默认 INFO:回看上一节 object.py:11INFO: int = 20,这是日志级别常量。20 是标准 logging 模块的 INFO 级别数值。

LogData 经 EventEngine 包装成 Event("eLog", log) 后,被 GUI 监听器消费,在主界面日志栏追加显示。所以 LogData 不只是写文件,更是 UI 数据流的一部分。

六、ContractData:合约规格(字段最丰富)

object.py:232-261:

232 @dataclass 233 class ContractData(BaseData): 238 symbol: str 239 exchange: Exchange 240 name: str 241 product: Product 242 size: float 243 pricetick: float 244 245 min_volume: float = 1 # 最小下单量 246 max_volume: float | None = None # 最大下单量 247 stop_supported: bool = False # 是否支持 STOP 单 248 net_position: bool = False # 网关是否用净持仓 249 history_data: bool = False # 是否提供历史数据 250 251 option_strike: float | None = None # 行权价 252 option_underlying: str | None = None # 标的合约 vt_symbol 253 option_type: OptionType | None = None # 看涨/看跌 254 option_listed: Datetime | None = None # 上市日 255 option_expiry: Datetime | None = None # 到期日 256 option_portfolio: str | None = None # 期权组合标识 257 option_index: str | None = None # 同行权价的区分索引 258 259 def __post_init__(self) -> None: 261 self.vt_symbol: str = f"{self.symbol}.{self.exchange.value}"

字段分两大组:

6.1 通用属性(所有合约都有)

  • symbol/exchange/name:合约代码、交易所、中文名。
  • product:产品类型(Product 枚举——股票/期货/期权/基金...)。
  • size:合约乘数。如股指期货 IF 一手 = 200 元 × 指数点,size=200。
  • pricetick:最小变动价位。如股指 0.2 点,螺纹钢 1 元。
  • min_volume/max_volume:下单量上下限。
  • stop_supported/net_position/history_data:三个能力开关——是否支持止损单、是否净持仓模式、是否提供历史数据。

⚠️ 重要说明:priceticksize 是下单风控的核心。下单价格必须是 pricetick 的整数倍(否则网关拒单),下单金额 = price × volume × size。VeighNa 风控引擎(后续章节)在 send_order 前会查 ContractData 验证这两项,所以 ContractData 必须先于下单被网关同步。

6.2 期权专属属性

option_strike(行权价)、option_type(看涨/看跌)、option_underlying(标的合约 vt_symbol)、option_listed(上市日)、option_expiry(到期日)等——只有 product=OPTION 时才有意义,其它合约这些字段保持 None。

特别注意 option_underlying 存的是标的合约的 vt_symbol(不是 symbol),因为要全局唯一定位标的。option_index 用于区分"同一行权价、同一类型"的多个期权合约(不同到期月)。

ContractData 的 __post_init__ 只生成一个 vt_symbol(和 TickData 一样),因为合约本身就是按 vt_symbol 全局唯一的实体。

七、Request 系列:出站请求

object.py:306-427 定义了一组请求类——SubscribeRequest/OrderRequest/CancelRequest/HistoryRequest/QuoteRequest。它们都不继承 BaseData(因为请求是出站的,不需要"来源 gateway_name"),但同样用 __post_init__ 拼 vt_symbol。

7.1 为什么 Request 不带 gateway_name

回看 BaseData(L17-26),它的核心字段就是 gateway_name。Request 系列故意不继承 BaseData,因为请求是"用户/策略 → 网关"的出站消息,目标网关由调用方(MainEngine.send_order 等)在路由时指定,而不是请求自身的属性。一个 OrderRequest 可以发给任意网关——它本身是网关无关的。

💡 核心心法:这是 VeighNa 区分 Data 与 Request 的关键——Data 是入站的,带 gateway_name(标记来源);Request 是出站的,不带 gateway_name(由路由时填入)。理解了这条线,你就理解了为什么 Request 不能继承 BaseData。

7.2 OrderRequest.create_order_data:Request → Data 的桥梁

object.py:339-355:

339 def create_order_data(self, orderid: str, gateway_name: str) -> OrderData: 343 order: OrderData = OrderData( 344 symbol=self.symbol, 345 exchange=self.exchange, 346 orderid=orderid, 347 type=self.type, 348 direction=self.direction, 349 offset=self.offset, 350 price=self.price, 351 volume=self.volume, 352 reference=self.reference, 353 gateway_name=gateway_name, 354 ) 355 return order

这是最关键的桥梁方法。流程是:

  1. 用户构造 OrderRequest(不带 gateway_name,只描述"想下什么单")。
  2. MainEngine.send_order(req, gateway_name) 把请求路由到指定网关。
  3. 网关本地生成 orderid,然后调用 req.create_order_data(orderid, gateway_name) 生成 OrderData。
  4. 这个 OrderData 通过 EventEngine 广播,策略和 UI 据此跟踪订单状态。

orderidgateway_name 都是路由阶段才确定的——所以 create_order_data 把它们作为参数传入,而 Request 自身只携带"业务意图"(symbol/type/direction/price/volume)。这就是为什么 Request 是"意图",Data 是"已发生事实"。

QuoteRequest(L390-427)的 create_quote_data 是同结构的镜像方法,处理询价请求。

本节要点回顾

  1. OrderData:字段分标识/委托要素/成交进度/元信息四组;两个 vt_ 键(vt_symbol + vt_orderid)。
  2. is_active():用 ACTIVE_STATUSES 集合判断活跃状态(提交中/未成交/部分成交),集合保证 O(1) 查询。
  3. TradeData:一笔订单可多笔成交;三个 vt_ 键,其中 vt_orderid 用于回溯订单、vt_tradeid 是自身唯一键。
  4. PositionData:vt_positionid 是 gateway.vt_symbol.direction 三元组,支持双向锁仓;yd_volume 标记昨仓。
  5. AccountData:available派生字段 = balance - frozen,在 __post_init__ 自动算;vt_accountid = gateway.accountid。
  6. LogData:time 是派生字段,Datetime.now() 自动盖时间戳。
  7. ContractData:字段分通用属性 + 期权专属;pricetick 与 size 是下单风控核心。
  8. Request 系列:不继承 BaseData(出站不带 gateway_name);OrderRequest.create_order_data 是 Request→Data 的桥梁,orderid 与 gateway_name 在路由阶段注入。

下一节,我们看这些数据类背后的"类型字典"——constant.py 的枚举体系与 i18n 国际化机制,看 VeighNa 怎么用一份枚举同时搞定类型约束、中英文显示和国产交易所适配。


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