周一早上九点二十分,你坐到某私募的研究台前,接手前同事留下的一段回测脚本。第一个函数是 def calc_pnl(ticks, holdings, fee): —— 参数到底是什么?ticks 是 list 还是 dict?holdings 的字段叫 symbol 还是 code?你只能把脚本跑一遍、撒几个 print、再去翻调用方。十分钟过去了,你才搞清楚:ticks 是 list[Tick],Tick 是一个有三个字段的轻量记录。这一课要把这十分钟压成零——用类型注解(type hints)和数据类(dataclass)让一份脚本在六个月后仍能被人一眼读懂。
一、类型注解的基本写法
函数参数和返回值的注解写在冒号与箭头后:
def average_price(ticks: list[Tick]) -> float:
return sum(t.price for t in ticks) / len(ticks)
读法:ticks 是 list[Tick](Tick 元素的列表),返回 float。变量也能注解,写在赋值左侧:tick: Tick = Tick('600519', 1820.50, 200)。常用类型有 int、float、str、bool,以及内建泛型 list[int]、dict[str, float]、tuple[str, int, float]。
允许空值时写 int | None(Python 3.10 及以上)——这是 Optional[int] 的等价短写法。三者关系是 Optional[T] ≡ T | None ≡ Union[T, None],新代码统一用 | 写法即可。这一整套语法也称类型提示(type hint),与「类型注解」可互换。
二、运行时不强制
类型注解的一个反直觉点:Python 默认不在运行时检查它们。下面这段代码能完全正常运行:
def f(x: int) -> int: return "hello"
调用 f(3) 会返回字符串 "hello",没有任何异常。注解本质上是一份「写给读者和静态检查器的文档」。
要把它从文档升级成保证,需要一个独立的静态类型检查(static type check)工具——业界事实标准是 mypy。在终端跑 mypy file.py,上面这个函数会被报为 Incompatible return value type (got "str", expected "int")。部分量化团队会默认在 CI 中开 mypy --strict,让任何类型不一致直接挂掉构建。(mypy 的安装与项目配置放在本模块第 4 课。)
经验判断什么 bug 它能抓、什么抓不到:拼错字段名、传错参数顺序、忘记处理 None——能抓;数值越界、浮点误差、业务逻辑错——抓不到。类型检查只是「形状对不对」的最低保障。
三、用 @dataclass 写记录
沪深300 成分股的盘后处理,最常见的数据形状是 (证券代码, 成交价, 成交量)。用 @dataclass 写出来:
from dataclasses import dataclass
@dataclass(frozen=True)
class Tick:
"""单条 A 股逐笔成交记录(沪深300 成分股口径)。"""
code: str
price: float
volume: int
构造与读取都直接:Tick('600519', 1820.50, 200)、Tick('000001', 12.34, 1500),访问写 tick.price。装饰器替你合成了 __init__、__repr__、__eq__——若用普通类,这三件套要手写二十多行;若用 dict,字段名拼错只能在运行时报 KeyError,IDE 也补全不出来。
frozen=True 让实例不可变:执行 tick.price = 0.0 会抛 FrozenInstanceError。不可变带来三件好处:(1) 可作为 dict 的键、set 的元素(hashable);(2) 多线程读取天然安全;(3) 一份历史数据在内存里不会被静默改写。代价是字段一旦构造就改不了。
可变的字段默认值要写 field(default_factory=list),不要直接写 holdings: list = []——后者所有实例共享同一个 list,会出现非常隐蔽的串数据问题。
顺带一句:
typing.NamedTuple是@dataclass(frozen=True)的前辈,写法更紧凑但只支持元组语义,新代码优先用 dataclass。
四、用 Protocol 描述「鸭子接口」
想象一个加权均价函数,希望对「任何带 price: float 字段的对象」都能工作——不管它是 Tick、是手写的 Holding、还是某个第三方 SDK 返回的对象。Java 的思路是抽一个基类让大家继承;Python 给了更轻的工具:
from typing import Protocol
class PricedRecord(Protocol):
price: float
def weighted_average(records: list[PricedRecord]) -> float:
return sum(r.price for r in records) / len(records)
任何具有 price: float 属性的类——包括上面定义的 Tick,以及一个 class Holding: code: str; shares: int; price: float——都自动满足 PricedRecord 这个协议(protocol),**完全不需要写 class Tick(PricedRecord)**。这是结构子类型(structural subtyping,又称鸭子类型的类型化版本):mypy 只检查形状,不要求继承关系。在量化代码里,这让你能在不修改既有数据类的前提下,给它们套上类型化的接口约束。
五、练习
Exercise
定义 @dataclass(frozen=True) class Trade,三个字段为 symbol: str、quantity: int、price: float,再加一个方法 notional(self) -> float,返回 self.quantity * self.price。验证:Trade("X", 100, 50.0).notional() == 5000.0 成立;对实例执行 t.price = 99.9 会抛出 FrozenInstanceError。
提示
dataclasses 导入 dataclass 与 FrozenInstanceError。装饰器写成 @dataclass(frozen=True),括号里的关键字参数不能省。提示
notional 是普通实例方法,定义在类体内、与字段同级,返回类型注解为 -> float;构造实例时按声明顺序传入三个位置参数。六、延伸阅读与下一课
延伸阅读:廖雪峰 Python 教程中的「类型注解」一节;《流畅的 Python》第 2 版第 8 章(类型提示)与第 5 章末(dataclass 部分);PEP 484、PEP 604 的中文社区翻译。
下一课转向另一种「让代码更易读」的工具:迭代器、生成器与上下文管理器。当你的 A 股逐笔流是几百万条记录的文件时,把 list[Tick] 换成 Iterator[Tick]、用 with open(...) as f 控制文件资源——内存占用会从 GB 级降到 MB 级,而调用方代码几乎不需要改动。