第 3 章 · 02 object.py 的交易与账户数据类 本节摘要:本节精读 的交易与账户侧数据类——OrderData/TradeData/PositionData/AccountData/ContractData/LogData,以及一组 Request 请求类。这一组类是 VeighNa "订单生命周期"的数据骨架:OrderData 跟踪委托状态、TradeData 记录每次成交、PositionData 持仓按多空方向分别建表、AccountData 派生 available 字段、ContractData 描述合约规格。
本节摘要:本节精读
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),精读并套用体系化模板。
阅读完本节,你应当能够:
vt_ 键(vt_symbol、vt_orderid)。is_active() 为什么用 ACTIVE_STATUSES 集合判断,以及"活跃订单"包含哪三种状态。vt_ 键——为什么 TradeData 多出一个 vt_tradeid。vt_positionid 为什么是三元组(gateway.vt_symbol.direction)。available 是派生字段,说出它的计算公式与触发时机。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 在所有订单里筛出本策略的委托,做归因分析。
__post_init__ 生成两个全局键:
134 self.vt_symbol: str = f"{self.symbol}.{self.exchange.value}" # 全局合约键 135 self.vt_orderid: str = f"{self.gateway_name}.{self.orderid}" # 全局订单键
gateway_name.orderid——因为不同网关可能有相同 orderid(本地自增 id),必须再拼一层 gateway_name 才全局唯一。💡 核心心法:OrderData 比 TickData 多一个
vt_orderid,因为订单的本地 id(orderid)由网关自定,跨网关会冲突。VeighNa 的统一解法是"网关前缀 + 本地 id"。这个套路贯穿所有需要"跨网关唯一"的对象——TradeData 的 vt_tradeid、QuoteData 的 vt_quoteid 都是同一招。
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 保证性能。
object.py:143-150:从 OrderData 直接构造一个 CancelRequest——把 orderid/symbol/exchange 三个本地字段打包,生成撤单请求。这是典型的"对象自服务"——上层不必手工 new CancelRequest 再挨个填字段,直接调 order.create_cancel_request() 即可。第 4 章 MainEngine 的 cancel_order 流程就会用到它。
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 之和。
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}" # 自身唯一键
💡 核心心法:TradeData 同时持有 vt_orderid 和 vt_tradeid,体现了它"既是订单的子记录,又是独立实体"的双重身份。VeighNa 的 OmsEngine(第 4 章)就是用 vt_orderid 把 TradeData 累加到 OrderData.traded 上,再用 vt_tradeid 防止同一笔成交被重复统计(幂等性)。
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 单独留字段标记昨仓数量。
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。
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。
214 self.available: float = self.balance - self.frozen # 派生字段 215 self.vt_accountid: str = f"{self.gateway_name}.{self.accountid}"
available(可用资金)不是用户传入的,而是在 __post_init__ 里现算的:balance - frozen。
💡 核心心法:为什么不让用户直接传 available?因为它是计算属性,如果允许传入就可能和 balance/frozen 不一致(数据腐败)。VeighNa 选择"只让用户填两个根字段,派生字段框架自动算",保证一致性。这是"单一数据源"(Single Source of Truth)的工程实践——派生字段永远从根字段算出来,绝不独立存储。
注意 AccountData 没有 symbol/exchange——因为账户是按资金账户维度,与具体合约无关。它的全局键是 vt_accountid = gateway.accountid,如 "CTP.000001"。
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 有两个"派生"特征:
__post_init__ 里 Datetime.now() 自动盖时间戳——用户构造日志时不必传时间,框架自动取当前时刻。这与 AccountData 的 available 同思路:能从"客观环境"算出的字段就不让用户填。object.py:11 的 INFO: int = 20,这是日志级别常量。20 是标准 logging 模块的 INFO 级别数值。LogData 经 EventEngine 包装成 Event("eLog", log) 后,被 GUI 监听器消费,在主界面日志栏追加显示。所以 LogData 不只是写文件,更是 UI 数据流的一部分。
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}"
字段分两大组:
⚠️ 重要说明:
pricetick和size是下单风控的核心。下单价格必须是 pricetick 的整数倍(否则网关拒单),下单金额 = price × volume × size。VeighNa 风控引擎(后续章节)在 send_order 前会查 ContractData 验证这两项,所以 ContractData 必须先于下单被网关同步。
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 全局唯一的实体。
object.py:306-427 定义了一组请求类——SubscribeRequest/OrderRequest/CancelRequest/HistoryRequest/QuoteRequest。它们都不继承 BaseData(因为请求是出站的,不需要"来源 gateway_name"),但同样用 __post_init__ 拼 vt_symbol。
回看 BaseData(L17-26),它的核心字段就是 gateway_name。Request 系列故意不继承 BaseData,因为请求是"用户/策略 → 网关"的出站消息,目标网关由调用方(MainEngine.send_order 等)在路由时指定,而不是请求自身的属性。一个 OrderRequest 可以发给任意网关——它本身是网关无关的。
💡 核心心法:这是 VeighNa 区分 Data 与 Request 的关键——Data 是入站的,带 gateway_name(标记来源);Request 是出站的,不带 gateway_name(由路由时填入)。理解了这条线,你就理解了为什么 Request 不能继承 BaseData。
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
这是最关键的桥梁方法。流程是:
req.create_order_data(orderid, gateway_name) 生成 OrderData。orderid 和 gateway_name 都是路由阶段才确定的——所以 create_order_data 把它们作为参数传入,而 Request 自身只携带"业务意图"(symbol/type/direction/price/volume)。这就是为什么 Request 是"意图",Data 是"已发生事实"。
QuoteRequest(L390-427)的 create_quote_data 是同结构的镜像方法,处理询价请求。
ACTIVE_STATUSES 集合判断活跃状态(提交中/未成交/部分成交),集合保证 O(1) 查询。available 是派生字段 = balance - frozen,在 __post_init__ 自动算;vt_accountid = gateway.accountid。time 是派生字段,Datetime.now() 自动盖时间戳。下一节,我们看这些数据类背后的"类型字典"——
constant.py的枚举体系与 i18n 国际化机制,看 VeighNa 怎么用一份枚举同时搞定类型约束、中英文显示和国产交易所适配。