数据模型设计(上):订单、成交、资产的数据类定义
做量化回测,说白了就是模拟真实交易。那模拟交易的第一步是什么?
我个人习惯,先把交易中涉及的核心对象定义清楚。订单怎么下?成交怎么记?账户资产怎么算?这些基础搞不定,后面写策略、算收益全是空中楼阁。
今天咱们就聊聊这三个最核心的数据类:Order(订单)、Fill(成交)、Portfolio(资产)。
1. 订单(Order)—— 交易的起点
订单就是你告诉系统「我想怎么买、怎么卖」。它承载了交易意图的全部信息。
我在项目中遇到过不少新手,订单字段设计得乱七八糟,结果回测时查 bug 查到崩溃。其实核心字段就那么几个,咱们一个一个说清楚。
1.1 订单的核心字段
| 字段名 | 类型 | 说明 |
|---|---|---|
| order_id | str | 唯一标识,我习惯用时间戳+随机数 |
| symbol | str | 股票代码,比如 '000001.SZ' |
| side | enum | BUY 或 SELL |
| order_type | enum | 市价单、限价单、冰山单等 |
| quantity | int | 委托数量(股) |
| price | float | 限价单的目标价格 |
| status | enum | PENDING, FILLED, CANCELLED 等 |
| created_time | datetime | 订单创建时间 |
嗯,这里要注意:quantity 我建议用整数。为什么?因为 A 股最小交易单位是 100 股(1手),用浮点数容易出精度问题。我曾经见过有人用 float 存数量,回测时出现 99.999999 股这种尴尬情况。
1.2 订单状态机
订单不是一成不变的。它有一个生命周期:
- PENDING:刚创建,还没处理
- PARTIALLY_FILLED:部分成交了
- FILLED:全部成交
- CANCELLED:被撤销
- REJECTED:被拒绝(比如价格超出涨跌停)
你想想看,如果状态设计少了,回测时遇到「部分成交后撤单」的情况,你根本没法准确模拟。
核心原则:订单状态必须能完整描述从创建到终态的每一个中间状态。别偷懒。
2. 成交(Fill)—— 交易的记录
订单是「我想做什么」,成交是「实际发生了什么」。两者必须分开。
为什么?因为一个订单可能对应多笔成交。比如你下了 1000 股的买单,可能分 3 次才买完。每笔成交都要单独记录。
2.1 成交的核心字段
| 字段名 | 类型 | 说明 |
|---|---|---|
| fill_id | str | 成交唯一标识 |
| order_id | str | 关联的订单 ID |
| symbol | str | 股票代码 |
| side | enum | BUY 或 SELL |
| filled_quantity | int | 本次成交数量 |
| filled_price | float | 本次成交价格 |
| commission | float | 手续费 |
| fill_time | datetime | 成交时间 |
小技巧:我习惯在 Fill 里直接记录 commission(手续费),而不是事后计算。这样在计算资产时直接累加就行,省得每次都要重新算一遍。
2.2 订单与成交的关系
一个 Order 对应多个 Fill。这是 1:N 的关系。
举个例子:
订单:买入 1000 股 贵州茅台,限价 1800 元
├── 成交1:300 股 @ 1798 元
├── 成交2:500 股 @ 1800 元
└── 成交3:200 股 @ 1800 元
订单状态:FILLED(全部成交)
这种设计在回测中特别有用。你可以精确模拟「滑点」—— 也就是实际成交价和预期价之间的差异。
3. 资产(Portfolio)—— 账户的总账本
Portfolio 就是你的账户快照。它告诉你此时此刻,你手里有多少钱、多少股票、总资产是多少。
3.1 资产的核心字段
| 字段名 | 类型 | 说明 |
|---|---|---|
| cash | float | 可用现金 |
| positions | dict | 持仓信息,key 是 symbol |
| total_value | float | 总资产 = cash + 持仓市值 |
| frozen_cash | float | 冻结资金(未成交订单占用的钱) |
| timestamp | datetime | 快照时间 |
这里有个坑:frozen_cash 很多人会忽略。你想想看,如果你下了买单但还没成交,这部分钱就不能再用来买别的股票了。不记录冻结资金,你的回测就会「超买」—— 明明没钱了还能继续下单。
3.2 持仓(Position)的数据结构
每个持仓对象包含:
- symbol:股票代码
- quantity:持有数量
- avg_cost:平均成本价
- market_value:当前市值
- pnl:盈亏 = (当前价 - 成本价) × 数量
注意:平均成本的计算方式要统一。我建议用「移动平均法」—— 每次买入时重新计算成本,卖出时不改变成本。这样最符合实际交易逻辑。
4. 核心逻辑流程图
下面这张图展示了订单、成交、资产三者之间的流转关系:
5. 代码实现示例
说了这么多理论,咱们直接看代码。我用 Python 的 dataclass 来实现,简洁又清晰。
from dataclasses import dataclass, field
from enum import Enum
from datetime import datetime
from typing import Dict, List, Optional
class OrderSide(Enum):
BUY = "BUY"
SELL = "SELL"
class OrderStatus(Enum):
PENDING = "PENDING"
PARTIALLY_FILLED = "PARTIALLY_FILLED"
FILLED = "FILLED"
CANCELLED = "CANCELLED"
REJECTED = "REJECTED"
@dataclass
class Order:
order_id: str
symbol: str
side: OrderSide
quantity: int
price: float
status: OrderStatus = OrderStatus.PENDING
filled_quantity: int = 0
created_time: datetime = field(default_factory=datetime.now)
@dataclass
class Fill:
fill_id: str
order_id: str
symbol: str
side: OrderSide
filled_quantity: int
filled_price: float
commission: float = 0.0
fill_time: datetime = field(default_factory=datetime.now)
@dataclass
class Position:
symbol: str
quantity: int = 0
avg_cost: float = 0.0
@dataclass
class Portfolio:
cash: float
positions: Dict[str, Position] = field(default_factory=dict)
frozen_cash: float = 0.0
timestamp: datetime = field(default_factory=datetime.now)
@property
def total_value(self) -> float:
# 这里需要传入当前价格来计算市值
# 实际使用时会在回测引擎中动态计算
return self.cash
个人经验:我建议把 commission 放在 Fill 里,而不是 Order 里。因为不同成交可能对应不同的手续费率(比如大单可以谈更低佣金)。这样设计更灵活。
6. 避坑指南
我曾经在回测框架里踩过几个坑,分享给你:
- 浮点数精度:现金和价格用 Decimal 或保留两位小数,别用 float 直接算。我吃过亏,回测结果和实盘差几分钱,查了两天。
- 订单 ID 唯一性:别用简单的自增 ID。多策略并行时容易重复。我习惯用
uuid或时间戳+策略ID+随机数。 - 成交时间:Fill 的时间戳必须精确到毫秒。同一秒内的多笔成交,顺序不同会导致不同的持仓成本。
- 冻结资金:别忘了记录。否则回测时你会发现自己「凭空多出钱来」。
好了,今天咱们把 Order、Fill、Portfolio 这三个核心数据类讲清楚了。说白了,它们就是回测系统的「三驾马车」。定义好了,后面的撮合逻辑、收益计算就顺理成章了。