数据模型设计(上):订单、成交、资产的数据类定义

做量化回测,说白了就是模拟真实交易。那模拟交易的第一步是什么?

我个人习惯,先把交易中涉及的核心对象定义清楚。订单怎么下?成交怎么记?账户资产怎么算?这些基础搞不定,后面写策略、算收益全是空中楼阁。

今天咱们就聊聊这三个最核心的数据类: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. 核心逻辑流程图

下面这张图展示了订单、成交、资产三者之间的流转关系:

Order 订单(交易意图) Fill 成交(实际记录) Portfolio 资产(账户快照) 1:N 更新 数据流转说明: 1. 策略生成 Order,提交到回测引擎 2. 引擎根据市场数据撮合,生成 Fill 3. 每个 Fill 更新 Portfolio 的 cash 和 positions 4. Portfolio 定期生成快照,用于计算收益曲线 注意:一个 Order 可能对应多个 Fill(部分成交)

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 这三个核心数据类讲清楚了。说白了,它们就是回测系统的「三驾马车」。定义好了,后面的撮合逻辑、收益计算就顺理成章了。


专注资料整理