Hualin Luan Cloud Native · Quant Trading · AI Engineering
返回文章

Article

量化交易系统开发实录(四):测试驱动敏捷开发(AI Agent 辅助)

从一个跨夜交易日边界 bug 出发,重构量化交易系统的测试防线:缺陷导向测试金字塔、AI TDD 分工、边界时间、数据血缘和 CI Gate。

Meta

Published

2026/3/30

Category

guide

Reading Time

约 42 分钟阅读

读者可以把这一篇当作量化交易系统的缺陷导向测试防线。Part2/Part3 的 100 个真实 pitfalls 不应该停留在问题清单里,它们需要被转化为可执行测试、回归用例、测试数据血缘和 CI Gate。测试不是为了堆数量,而是为了让真实缺陷无法再次穿过系统边界。

系列阅读顺序

推荐阅读路径是 Part1 -> Part2 -> Part3 -> Part4 -> Part5 -> Part6 -> Part7。Part4 位于性能优化之前,因为交易系统必须先证明结果可靠,再讨论速度、吞吐和渲染性能。

这一篇聚焦五个问题:

  • 为什么量化系统的测试应该从真实缺陷和风险族群反推,而不是从模板化覆盖率反推。
  • RED-GREEN-REFACTOR 在交易系统中如何保护 API 语义、边界时间和重构安全。
  • AI 生成测试时,哪些责任可以交给 AI,哪些验收标准必须由人签收。
  • 边界时间、属性测试、模糊测试、回测一致性和测试数据血缘如何组合成交易系统测试防线。
  • AI 如何辅助集成测试和 E2E 测试,同时通过需求契约追踪识别实现偏移。
  • CI Gate 如何把单元测试、集成测试、属性测试、边界时间测试和覆盖率报告变成合并门槛。

引子:一个未被测试覆盖的边界情况

上线前最危险的缺陷,通常不是每天都会出现的错误,而是只在特定日期、特定交易时段、特定数据组合下触发的错误。一个典型场景是:同一套策略在周一多出一根 K 线,导致指标窗口错位。普通测试用例全部通过,但真实交易日语义已经被污染。

根因来自香港期货的跨夜交易机制。周五夜盘和周一早盘在交易语义上可能属于同一个交易日,但物理时间跨过了周五、周六、周日和周一。若聚合逻辑只按自然日切分,周五夜盘、跨午夜数据和周一早盘就可能被错误拆分,日线、指标和回测结果都会被连带污染。

这个缺陷暴露的不是“测试数量不够”,而是测试设计没有对准真实风险。有效测试至少要回答三个问题:风险属于语法、状态、并发、时间、数据、GUI、网络还是安全边界;最小复现数据是什么;应该由单元测试、属性测试、集成测试、端到端测试还是 CI Gate 拦截。

第一部分:量化系统为什么难测试

量化系统难测试,不是因为测试框架复杂,而是因为被测对象同时叠加了数据、时间、状态、随机性和外部依赖。

第一类挑战是数据依赖。策略逻辑依赖历史行情,但完整历史数据体量大,不能全部提交到版本控制;真实数据可能包含敏感信息,不能公开分享;交易所也可能调整历史数据,导致旧样本失效。因此测试数据不能只依赖“某一份本地 CSV”,而需要合成数据、脱敏快照、契约测试和数据血缘共同支撑。

第二类挑战是随机性。网络延迟不确定,行情推送存在 jitter,浮点数计算存在精度误差,回测和模拟盘之间还可能因为事件顺序不同产生差异。测试不能只断言“某个值完全等于另一个值”,还需要明确容忍误差、事件顺序和可重复随机种子。

第三类挑战是状态复杂。持仓状态、订单状态、资金状态、分层数据归一处理状态和策略内部指标状态都可能跨事件延续。一个测试如果只看单个函数返回值,就很容易漏掉状态生命周期问题,例如部分成交后取消、夜盘跨日后指标窗口恢复、重连后订阅状态重复等。

第四类挑战是边界情况密集。开盘、收盘、午休、夜盘、半日市、节假日、跨周、跨日 K 线、数据缺失、网络中断、行情延迟和风控冻结都属于高风险边界。真正有效的测试金字塔,应该从这些缺陷入口反推测试层级。

量化交易系统缺陷导向测试金字塔
图 1:缺陷导向测试金字塔,底层覆盖纯函数和时间语义,上层验证策略、回测与实盘一致性。

这张图的重点是“缺陷导向”。底层测试覆盖纯函数、指标计算和时间语义,因为这些错误会被上层策略反复放大;中层测试覆盖数据源、聚合器、策略事件和测试替身,因为这里连接多个模块;上层测试覆盖回测、模拟盘和实盘一致性,因为这里验证系统是否真的按业务语义运行;最顶层 CI Gate 负责阻断合并,而不是替代底层测试。

第二部分:TDD 基础:RED-GREEN-REFACTOR

TDD 的核心循环是 RED-GREEN-REFACTOR。对交易系统来说,它的价值不是形式化地“先写测试”,而是用失败测试定义行为边界,用最小实现验证测试有效,再在测试保护下重构。

RED 阶段的目标是用测试定义正确性。以简单移动平均为例,测试先定义 [1, 2, 3, 4, 5]period=3 时的结果是 [None, None, 2.0, 3.0, 4.0]。这个测试应该先失败,原因可以是类不存在、方法不存在或行为不满足预期。失败本身是有效信号:它证明测试确实能抓住当前缺口。

def test_sma_calculates_correctly():
    prices = [1, 2, 3, 4, 5]
    sma = SimpleMovingAverage(period=3)

    result = sma.calculate(prices)

    assert result == [None, None, 2.0, 3.0, 4.0]

GREEN 阶段的目标是用最小实现让测试通过。最小实现甚至可以先硬编码,只要它证明测试本身有效。随后再进入真正实现:前 period - 1 个结果为 None,第一个窗口使用初始和,后续通过滑动窗口增量更新。

class SimpleMovingAverage:
    def __init__(self, period: int):
        self.period = period

    def calculate(self, prices: list[float]) -> list[float | None]:
        if len(prices) < self.period:
            return [None] * len(prices)

        result: list[float | None] = [None] * (self.period - 1)
        window_sum = sum(prices[: self.period])
        result.append(window_sum / self.period)

        for index in range(self.period, len(prices)):
            window_sum += prices[index] - prices[index - self.period]
            result.append(window_sum / self.period)

        return result

REFACTOR 阶段的目标是在不改变外部行为的前提下改进结构。交易系统里的重构尤其需要测试保护,因为指标计算、数据归属和策略信号都可能被局部改动影响。测试持续绿色,才说明重构没有改变已定义行为。

TDD 对交易系统的直接收益可以压缩成一张表:

维度没有 TDD有 TDD
代码覆盖率常见停留在 30% 左右可稳定推进到 80% 以上
Bug 逃逸率边界缺陷容易穿透真实缺陷可沉淀为回归用例
重构信心不敢改核心模块可以分阶段重构并持续验证
API 设计由实现细节反推由测试场景驱动
文档价值文档滞后于代码测试成为可运行文档

第三部分:测试模式与结构

测试结构决定读者能否快速理解一个用例在验证什么。量化系统里最常用的三类结构是 AAA、Given-When-Then 和表格驱动测试。它们解决的是不同层级的问题:AAA 让函数行为清楚,Given-When-Then 让业务场景清楚,表格驱动让边界集合清楚。

AAA 适合函数和服务级测试。Arrange 准备数据和依赖,Act 执行被测操作,Assert 验证结果。下面的骨架虽然简单,但它能防止测试把准备过程、动作和断言混在一起。

# 示意代码,非实际生产代码
def test_xxx():
    """测试描述"""
    # Arrange: 准备测试数据和环境
    input_data = ...
    expected_output = ...

    # Act: 执行被测操作
    actual_output = function_under_test(input_data)

    # Assert: 验证结果
    assert actual_output == expected_output

读者看 AAA 测试时,应该先检查三个问题:输入是否最小,动作是否只有一个,断言是否直接验证业务行为。盈亏计算是最适合 AAA 的例子,因为它的输入、动作和输出边界非常清楚。

# 示意代码,非实际生产代码
def test_calculate_profit():
    """测试盈亏计算"""
    # Arrange
    entry_price = 25000
    exit_price = 25100
    position_size = 10
    expected_profit = 1000  # (25100 - 25000) * 10

    # Act
    actual_profit = calculate_profit(
        entry=entry_price,
        exit=exit_price,
        size=position_size,
    )

    # Assert
    assert actual_profit == expected_profit

这段代码的价值不在于公式复杂,而在于它把交易系统里最容易被放大的基础语义固定下来。盈亏计算如果没有独立测试,后面的持仓、风控、回测统计和绩效归因都会建立在不可靠基础上。

Given-When-Then 更适合策略行为和验收场景。它让测试读起来像业务规格:给定某个市场状态,当策略处理事件时,应该产生某个交易行为。

# 示意代码,非实际生产代码
def test_user_story_scenario():
    """场景描述"""
    # Given: 初始状态
    setup_conditions()

    # When: 触发事件
    perform_action()

    # Then: 期望结果
    verify_expected_outcomes()

策略金叉买入就是典型场景。测试不应该只验证某个内部变量变化,而要验证策略面对最后一根 Bar 时,是否生成了可执行的业务信号。

# 示意代码,非实际生产代码
def test_strategy_generates_buy_signal():
    """策略在金叉时生成买入信号"""
    # Given: MA5 上穿 MA10
    strategy = MovingAverageCrossStrategy(fast=5, slow=10)
    historical_data = load_data_with_golden_cross()

    # When: 策略处理最后一根 Bar
    signal = strategy.on_bar(historical_data[-1])

    # Then: 生成买入信号
    assert signal.action == Action.BUY
    assert signal.price == historical_data[-1].close

这段代码把“金叉”从口头描述变成了可运行规格。读者应关注的是 Given 部分是否真的构造出金叉,而不是只靠函数名暗示场景;Then 部分是否验证了动作和价格,而不是只验证返回对象不为空。

表格驱动测试适合大量边界值和等价类。新增场景只需要增加一行数据,pytest 参数化能保证每个用例独立运行,并在失败时显示具体场景。

# 示意代码,非实际生产代码
import pytest

TEST_CASES = [
    # input, expected, description
    ([1, 2, 3], 2.0, "正整数"),
    ([-1, -2, -3], -2.0, "负整数"),
    ([1.5, 2.5, 3.5], 2.5, "浮点数"),
    ([5, 5, 5], 5.0, "相同值"),
    ([1], 1.0, "单元素"),
]

@pytest.mark.parametrize("input_data,expected,desc", TEST_CASES)
def test_average_calculation(input_data, expected, desc):
    """测试平均值计算"""
    result = calculate_average(input_data)
    assert result == expected, f"Failed on {desc}"

表格驱动测试的关键是把“有哪些输入类别”显式写出来。量化系统里可以把价格序列、交易时段、订单状态、数据缺失模式和异常输入都放进表格,而不是复制多段几乎相同的测试。

模式适用场景风险拦截点
AAA纯函数、指标、盈亏计算输入、动作、输出清晰分离
Given-When-Then策略信号、用户故事、验收场景业务语义和行为结果绑定
表格驱动多组边界值、等价类、异常输入防止只测一个 happy path

第四部分:测试替身:隔离外部依赖

量化系统大量依赖外部数据源、订单管理器、数据库、日志和交易网关。测试替身的目的不是“mock 一切”,而是隔离不可控依赖,同时保留要验证的行为。Dummy、Stub、Spy、Mock 和 Fake 解决的问题并不相同,混用会让测试变脆。

Dummy 是占位对象,只用于满足参数要求。当前测试不验证日志行为时,logger 可以是一个不会被使用的对象。

# 示意代码,非实际生产代码
def test_dummy_logger_does_not_affect_fetch():
    """Dummy 只填充参数,不参与断言"""
    dummy_logger = object()
    service = DataService(logger=dummy_logger)

    result = service.fetch_data("HSI")

    assert result is not None

这段代码只证明 DataService.fetch_data 不依赖 logger 行为。读者不应该在 Dummy 测试里断言日志调用,否则 Dummy 就被错误地升级成 Mock。

Stub 是固定返回值对象,适合替代真实行情源。策略只需要一个确定价格时,Stub 可以保证测试不依赖真实 API、网络延迟和行情权限。

# 示意代码,非实际生产代码
class StubDataFeed:
    """返回固定价格的行情源"""
    def __init__(self, fixed_price: float):
        self.fixed_price = fixed_price

    def get_price(self, symbol: str) -> float:
        return self.fixed_price

def test_strategy_with_stub_price():
    """使用 Stub 测试策略在固定价格下的行为"""
    stub_feed = StubDataFeed(fixed_price=25000)
    strategy = SimpleStrategy(data_feed=stub_feed)

    signal = strategy.check_signal()

    assert signal is not None

Stub 的读者价值是稳定输入。只要测试目标是策略逻辑,就不应该让真实行情服务成为失败来源。

Spy 记录被测对象与协作者的交互,适合验证下单次数、方向和数量。它不预设复杂期望,只把交互记录下来供断言使用。

# 示意代码,非实际生产代码
class SpyOrderManager:
    """记录所有下单调用的 Spy"""
    def __init__(self):
        self.orders_placed = []

    def place_order(self, symbol: str, side: str, quantity: int):
        self.orders_placed.append({
            "symbol": symbol,
            "side": side,
            "quantity": quantity,
        })

def test_strategy_places_buy_order_once():
    """验证策略只下达一笔 BUY 订单"""
    spy = SpyOrderManager()
    strategy = SignalStrategy(order_manager=spy)

    strategy.on_signal(Signal.BUY)

    assert len(spy.orders_placed) == 1
    assert spy.orders_placed[0]["side"] == "BUY"

这段代码保护的是订单副作用边界。读者应关注“只下一笔”和“方向正确”两个断言,因为重复下单和方向错误都可能进入真实风险。

Mock 预设期望并验证调用行为,适合检查某个方法是否按指定参数调用。Mock 过度使用会让测试绑定实现细节,所以只在交互本身就是行为的一部分时使用。

# 示意代码,非实际生产代码
from unittest.mock import Mock

def test_strategy_calls_data_feed_with_symbol():
    """验证策略按约定请求 HSI 行情"""
    mock_feed = Mock()
    mock_feed.get_price.return_value = 25000

    strategy = Strategy(data_feed=mock_feed)
    strategy.update()

    mock_feed.get_price.assert_called_once_with("HSI")
    assert mock_feed.get_price.call_count == 1

这段代码的关键断言是 assert_called_once_with("HSI")。如果策略请求了错误合约、重复请求或遗漏请求,Mock 能立刻暴露接口交互错误。

Fake 是简化但真实可用的实现,例如内存数据库。Repository 测试使用 Fake 可以保留读写语义,同时避免真实数据库连接、事务和清理成本。

# 示意代码,非实际生产代码
class FakeDatabase:
    """内存数据库 Fake"""
    def __init__(self):
        self.data = {}

    def save(self, key: str, value: dict):
        self.data[key] = value

    def get(self, key: str) -> dict:
        return self.data.get(key)

    def delete(self, key: str):
        if key in self.data:
            del self.data[key]

def test_repository_with_fake_db():
    """使用 Fake 数据库测试 Repository"""
    fake_db = FakeDatabase()
    repo = BarRepository(database=fake_db)

    bar = BarData(symbol="HSI", close=25000)
    repo.save(bar)

    retrieved = repo.get("HSI")
    assert retrieved.close == 25000

Fake 的价值是保留真实语义。读者可以把它理解为“轻量但可运行的替代实现”,它比 Mock 更适合测试读写流程和 Repository 边界。

替身类型用途量化系统示例
Dummy填充不会被使用的参数不参与断言的 logger
Stub返回固定响应固定价格行情源
Spy记录交互记录策略下单次数
Mock验证调用行为验证行情接口调用参数
Fake简化真实实现内存数据库或本地订单簿

第五部分:AI 辅助 TDD 流程

AI 辅助 TDD 不应该让 AI 接管验收标准。更稳妥的分工是:人定义缺陷、业务语义和验收边界;AI 扩展测试框架、生成候选实现、提出重构建议;最终由人审查测试是否真的覆盖风险。

AI 辅助 TDD 人机分工泳道图
图 2:AI TDD 泳道图,AI 扩展候选,人保留缺陷定义、验收标准和最终签收权。

传统 TDD 的三步是人工写测试、人工写实现、人工重构。AI 辅助 TDD 可以改成:人工写规格和关键边界,AI 生成测试框架;AI 生成候选实现,人工 Review;AI 提供重构建议,人工判断是否接受。这样做能提升速度,但不会把业务语义签收权交给 AI。

K 线周期聚合是一个完整样例。规格先定义:给定 1 分钟 K 线数据,生成 5 分钟 K 线;输入包含 open/high/low/close/volume/turnover;输出为 5 分钟 K 线列表。核心规则包括:边界归一到 09:00、09:05、09:10 等周期边界;open 取第一根,high 取最大值,low 取最小值,close 取最后一根,volumeturnover 求和。边界情况包括:不足 5 根不生成、跨越 5 分钟边界正确分割、时间戳不落在边界时向下取整。

规格应该先用自然语言锁定业务规则,再交给 AI 扩展测试。下面这段规格不是装饰性文档,它限定了 AI 不能随意解释聚合语义。

## 功能:K 线周期聚合

### 需求
给定 1 分钟 K 线数据,生成 5 分钟 K 线数据。

### 输入
- 1 分钟 K 线列表,包含 open, high, low, close, volume, turnover

### 输出
- 5 分钟 K 线列表

### 规则
1. 边界归一:5 分钟 K 线起始时间为 09:00, 09:05, 09:10 ...
2. 价格计算:
   - open = 第一根 1 分钟线的 open
   - high = max(1 分钟线 high)
   - low = min(1 分钟线 low)
   - close = 最后一根 1 分钟线的 close
3. 成交量累加:volume = sum(1 分钟线 volume)

### 边界情况
- 数据不足 5 根:不生成 K 线
- 数据跨越 5 分钟边界:正确分割
- 时间戳不落在边界:向下取整到 5 分钟边界

读者可以把这段规格当成 AI 生成测试的输入契约。没有它,AI 很容易生成“看起来合理但语义不确定”的测试,例如忽略边界归一或把剩余不足周期的数据也聚合出来。

给 AI 的测试生成 prompt 应该明确测试策略、样本工厂和断言目标。

基于以上规格,生成 pytest 测试代码。

要求:
1. 使用 AAA 模式。
2. 包含正常、边界、异常三种情况。
3. 使用参数化测试覆盖多种输入。
4. 测试数据使用工厂函数生成。
5. 每个测试有清晰的 docstring。

下面的测试框架保留了原版中的关键细节:工厂函数负责生成时间连续的 Bar,正常用例验证 OHLCV 聚合,边界用例验证不足周期不生成,参数化用例验证输入数量和输出数量的关系。

# 示意代码,非实际生产代码
import pytest
from datetime import datetime, timedelta
from typing import List

from core.aggregation import aggregate_bars
from core.data import BarData


class TestAggregateBars:
    """测试 K 线周期聚合"""

    @staticmethod
    def create_bars(count: int, start_time: datetime) -> List[BarData]:
        """工厂函数:创建测试数据"""
        bars = []
        for i in range(count):
            bars.append(
                BarData(
                    symbol="HSI",
                    timestamp=start_time + timedelta(minutes=i),
                    open_price=100 + i,
                    high_price=105 + i,
                    low_price=95 + i,
                    close_price=100 + i + 0.5,
                    volume=1000,
                    turnover=100000,
                )
            )
        return bars

    def test_normal_case(self):
        """正常情况:5 根 1 分钟线合成 1 根 5 分钟线"""
        start_time = datetime(2024, 1, 8, 9, 0)
        bars = self.create_bars(5, start_time)

        result = aggregate_bars(bars, period=5)

        assert len(result) == 1
        assert result[0].open_price == 100
        assert result[0].high_price == 109
        assert result[0].low_price == 95
        assert result[0].close_price == 104.5
        assert result[0].volume == 5000

    def test_insufficient_data(self):
        """边界情况:不足 5 根不生成"""
        start_time = datetime(2024, 1, 8, 9, 0)
        bars = self.create_bars(3, start_time)

        result = aggregate_bars(bars, period=5)

        assert len(result) == 0

    @pytest.mark.parametrize("input_count,expected_count", [
        (5, 1),
        (10, 2),
        (12, 2),  # 剩余 2 根不足
        (0, 0),
    ])
    def test_various_counts(self, input_count, expected_count):
        """参数化测试:不同输入数量"""
        start_time = datetime(2024, 1, 8, 9, 0)
        bars = self.create_bars(input_count, start_time)

        result = aggregate_bars(bars, period=5)

        assert len(result) == expected_count

这段测试的架构价值在于它先定义输出语义,再允许实现变化。只要这些测试存在,后续无论使用循环、NumPy、向量化还是增量状态,都必须保留同样的业务结果。

RED 阶段应该先失败。失败不是坏事,它证明测试确实能捕获当前缺口。

$ pytest tests/test_aggregation.py -v

tests/test_aggregation.py::TestAggregateBars::test_normal_case FAILED
tests/test_aggregation.py::TestAggregateBars::test_insufficient_data FAILED
tests/test_aggregation.py::TestAggregateBars::test_various_counts FAILED

# 失败原因:aggregate_bars 函数不存在

这段输出让读者确认测试不是“事后补绿”。如果测试一开始就通过,很可能是测试没有覆盖真实缺口,或者实现早已存在但未被约束到业务语义。

GREEN 阶段的实现只需要满足已定义测试,不要提前加入过多抽象。下面的实现保留了核心聚合逻辑:按完整周期切片,分别计算 open、high、low、close、volume 和 turnover。

# 示意代码,非实际生产代码
from typing import List

def aggregate_bars(bars: List[BarData], period: int) -> List[BarData]:
    """将分钟 K 线聚合为更大的周期"""
    if len(bars) < period:
        return []

    result = []
    for i in range(0, len(bars) // period * period, period):
        group = bars[i:i + period]
        aggregated = BarData(
            symbol=group[0].symbol,
            timestamp=group[0].timestamp,
            open_price=group[0].open_price,
            high_price=max(b.high_price for b in group),
            low_price=min(b.low_price for b in group),
            close_price=group[-1].close_price,
            volume=sum(b.volume for b in group),
            turnover=sum(b.turnover for b in group),
        )
        result.append(aggregated)

    return result

这段实现只解决当前测试覆盖的行为。读者应注意它仍然有改进空间,例如时间戳边界归一逻辑还没有独立出来,剩余不足周期的处理策略还依赖测试约束。

REFACTOR 阶段再抽出边界归一和聚合构造函数。重构的前提是测试持续通过,目标是让边界语义更容易被下一位维护者理解。

# 示意代码,非实际生产代码
def aggregate_bars(bars: List[BarData], period: int) -> List[BarData]:
    """将分钟 K 线聚合为更大的周期"""
    if len(bars) < period:
        return []

    result = []
    end = len(bars) - len(bars) % period
    for i in range(0, end, period):
        group = bars[i:i + period]
        aligned_time = _align_timestamp(group[0].timestamp, period)
        result.append(_create_aggregated_bar(group, aligned_time))

    return result


def _align_timestamp(timestamp: datetime, period: int) -> datetime:
    """将时间戳归一到周期边界"""
    minute = (timestamp.minute // period) * period
    return timestamp.replace(minute=minute, second=0, microsecond=0)


def _create_aggregated_bar(group: List[BarData], timestamp: datetime) -> BarData:
    """从一组 K 线创建聚合 K 线"""
    return BarData(
        symbol=group[0].symbol,
        timestamp=timestamp,
        open_price=group[0].open_price,
        high_price=max(b.high_price for b in group),
        low_price=min(b.low_price for b in group),
        close_price=group[-1].close_price,
        volume=sum(b.volume for b in group),
        turnover=sum(b.turnover for b in group),
    )

重构后的代码让边界归一成为显式边界。读者应关注 _normalize_boundary_timestamp 是否覆盖交易所真实交易时段;如果后续要支持夜盘、半日市或跨周归属,这个函数会成为重点测试入口。

第六部分:量化系统特有的测试策略

量化测试不能只依赖手写样例。手写样例适合说明业务意图,但很难穷尽输入空间。交易系统至少需要四类专属策略:属性基于测试、模糊测试、回测一致性验证和边界时间测试。

属性基于测试适合验证数学性质。移动平均的核心属性包括:结果长度与输入长度和周期相关;每个均值应落在对应窗口的最小值和最大值之间;当输入序列单调不降时,移动平均也应单调不降。Hypothesis 可以自动生成大量价格序列和周期,帮助发现人工样本漏掉的边界。

# 示意代码,非实际生产代码
from hypothesis import given, strategies as st

@given(
    prices=st.lists(
        st.floats(min_value=1, max_value=100000),
        min_size=10,
        max_size=100,
    ),
    period=st.integers(min_value=2, max_value=20),
)
def test_ma_properties(prices, period):
    """测试移动平均线的数学属性"""
    result = calculate_ma(prices, period)

    assert len(result) == len(prices) - period + 1

    for value in result:
        assert min(prices) <= value <= max(prices)

    if all(prices[i] <= prices[i + 1] for i in range(len(prices) - 1)):
        assert all(result[i] <= result[i + 1] for i in range(len(result) - 1))

这段测试的价值是从“举几个例子”升级为“验证数学性质”。读者应注意第二个断言写成了全局范围约束;如果要更严格,可以把每个均值限定在对应滑动窗口的 min/max 之间。

模糊测试适合验证解析逻辑的健壮性。行情解析器面对任意字节输入时,允许抛出明确的 ParseError,但不应该出现未预期异常;如果解析成功,还要满足 high >= lowhigh >= openhigh >= close 等基本约束。

# 示意代码,非实际生产代码
import atheris
import sys

@atheris.instrument_func
def test_parse_bar(input_bytes):
    """测试行情解析的健壮性"""
    fdp = atheris.FuzzedDataProvider(input_bytes)
    data = fdp.ConsumeBytes(len(input_bytes))

    try:
        bar = parse_bar_data(data)
        if bar:
            assert bar.high >= bar.low
            assert bar.high >= bar.open
            assert bar.high >= bar.close
    except ParseError:
        pass
    except Exception:
        raise

atheris.Setup(sys.argv, test_parse_bar)
atheris.Fuzz()

这段代码让脏数据持续冲击解析器。读者应关注异常边界:业务上可接受的解析失败应该进入 ParseError,未知异常才是需要修复的缺陷。

回测一致性验证用于保证策略在回测和模拟盘中表现一致。这个测试保护的是“回测和实盘走同一套业务语义”,不是单个函数。

# 示意代码,非实际生产代码
def test_strategy_consistency():
    """验证策略在回测和模拟盘中表现一致"""
    historical_data = load_data("HSI", "2024-01-01", "2024-01-31")

    backtest_result = run_backtest(
        strategy=MyStrategy(),
        data=historical_data,
        mode="backtest",
    )

    simulated_result = run_backtest(
        strategy=MyStrategy(),
        data=historical_data,
        mode="simulation",
    )

    assert len(backtest_result.signals) == len(simulated_result.signals)
    for b_sig, s_sig in zip(backtest_result.signals, simulated_result.signals):
        assert b_sig.timestamp == s_sig.timestamp
        assert b_sig.action == s_sig.action
        assert abs(b_sig.price - s_sig.price) < 0.01

这段代码不应该被理解为“回测收益和模拟盘收益完全一样”。更准确的目标是:同一份事件输入下,信号数量、时间戳、动作和价格语义应保持一致,允许的误差必须显式写入断言。

边界时间测试是量化系统最重要的回归防线之一。周五夜盘到周一早盘、半日市、午休、开盘第一根、收盘最后一根、节假日前夕夜盘都应该有固定 fixture。

# 示意代码,非实际生产代码
class TestHKFEBoundaryCases:
    """测试港股期货的边界时间情况"""

    def test_friday_night_to_monday(self):
        """周五夜盘到周一早盘的跨日交易"""
        friday_night = datetime(2024, 1, 5, 23, 0)
        monday_morning = datetime(2024, 1, 8, 9, 15)

        bars = [
            BarData(timestamp=friday_night, close=25000),
            BarData(timestamp=monday_morning, close=25100),
        ]

        result = aggregate_daily(bars)

        assert len(result) == 1
        assert result[0].date == date(2024, 1, 5)

    def test_half_day_holiday(self):
        """半日市提前收盘"""
        half_day_close = datetime(2024, 2, 9, 12, 0)
        bars = generate_bars_until(half_day_close)

        assert all(bar.timestamp <= half_day_close for bar in bars)

这段代码把跨夜、跨周和半日市变成固定回归样本。读者应关注的是交易日归属规则,而不是物理日期是否连续。

量化交易系统边界时间测试地图
图 3:边界时间测试状态图,把开盘、收盘、夜盘、跨周和半日市作为显式测试状态。

图 3 把边界时间看成状态迁移,而不是日期列表。测试需要覆盖从日盘进入午休、从午休恢复、从日盘进入夜盘、跨午夜、跨周归属、半日市提前收盘和假期休市。只要这些状态没有显式测试,聚合器和策略就会继续依赖隐含假设。

第七部分:AI 生成测试的最佳实践

让 AI 生成更好的测试,关键是给它测试策略,而不是只说“补测试”。

第一,明确测试方法。可以要求 AI 使用边界值分析法,分别生成正常范围、边界值和异常输入;也可以要求使用等价类划分,把价格、时间、交易日、订单状态拆成不同类别。

使用边界值分析法,为以下函数生成测试:
- 正常情况:输入在有效范围内
- 边界情况:输入在边界上
- 异常情况:输入超出范围

这段 prompt 的价值在于强制 AI 先组织测试空间。读者可以继续补充“每个用例必须说明风险来源和断言目标”,防止 AI 只给出表面覆盖。

第二,要求 AI 解释测试意图。每个测试都应该说明验证的业务假设,例如“周五 23:30 的夜盘 Bar 应归属于下周一交易日,而不是自然日周五”。

请为以下测试添加注释,解释每个测试的目的和验证的假设。

如果 AI 解释不清楚,它生成的测试通常也不可靠。解释测试意图不是为了增加注释,而是为了让业务假设可审查。

第三,让 AI 识别遗漏。把实现、规格和已有测试交给 AI,让它列出可能遗漏的场景,通常能发现空输入、极大值、极小值、并发访问、资源耗尽和浮点精度等问题。

基于以下规格和实现,列出可能遗漏的测试场景。
请按正常、边界、异常、并发、性能、回归六类输出。
每个场景必须说明风险来源、最小复现数据和断言目标。

这段 prompt 把“补测试”变成了风险分类任务。读者应把 AI 输出当作候选清单,再结合真实缺陷和系统边界决定是否采纳。

第四,用变异测试检查测试质量。mutmut 这类工具会修改源代码,例如把 > 改成 >=,再运行测试。如果测试仍然通过,说明测试没有真正保护行为。

# 示意代码,非实际生产代码
# 安装:pip install mutmut
# 运行:mutmut run

# mutmut 会修改源代码,例如把 > 改成 >=。
# 如果测试仍然通过,说明测试没有真正约束行为。

变异测试不适合每次提交都跑,但适合在关键模块重构前后使用。它能暴露“覆盖率很高但断言很弱”的假象。

第五,让 AI 生成集成测试和 E2E 测试时,必须先限定系统边界。最常见的问题不是 AI 不会写测试代码,而是它把 E2E 写成了“能跑通页面或命令”的 happy path,却没有验证业务链路是否真的闭合。量化系统的集成测试至少要穿过数据源、交易日归属、周期聚合、指标计算、策略信号和订单适配器;E2E 测试还要验证读者真正关心的结果,例如信号时间戳、下单方向、数量、价格容差、回测报告和异常降级路径。

基于以下需求契约生成集成测试和 E2E 测试。

要求:
1. 每个测试必须标注 REQ/INV/E2E ID。
2. 集成测试必须覆盖 DataFeed -> Calendar -> Aggregator -> Strategy -> OrderAdapter。
3. E2E 测试必须验证业务可见结果,不允许只断言页面可打开或命令退出码为 0。
4. 外部行情和交易网关必须使用 Fake 或 Stub,但断言必须覆盖真实业务语义。
5. 每个测试必须输出 evidence 路径、断言对象和失败时的定位线索。

这段 prompt 的关键是把“生成 E2E”改成“按契约生成可追踪 E2E”。读者应避免让 AI 只写浏览器点击、CLI smoke test 或 mock-only 断言。一个合格的 E2E 至少要证明某个需求从输入数据进入系统后,经过真实集成边界,最终产生可审查的业务结果。

第六,需求契约执行完成后,需要有独立的追踪工件,而不是只看测试是否通过。Trace Matrix 应该把需求、约束、不变量、测试、证据和状态放在同一张表里。这样才能识别实现偏移:代码写了但没有对应需求,测试通过但没有证据,E2E 只覆盖 happy path,或者实现路径绕过了契约中定义的边界。

trace:
  - id: TRACE-HKFE-001
    requirement: REQ-TRADING-DAY-OWNERSHIP
    invariant: INV-NATURAL-DAY-MUST-NOT-LEAK
    integration_test: tests/integration/test_hkfe_daily_aggregation.py
    e2e_test: tests/e2e/test_strategy_signal_calendar_boundary.py
    evidence:
      - reports/integration/hkfe_daily_aggregation.xml
      - reports/e2e/strategy_signal_calendar_boundary.json
    drift_signals:
      - implementation_without_requirement
      - passing_test_without_business_assertion
      - mock_only_without_integration_boundary
      - missing_evidence_artifact
    status: PASS

这段追踪工件的价值是让“完成”变成可审查状态。读者可以沿着 TRACE-HKFE-001 看到需求来自哪里、不变量是什么、集成测试和 E2E 测试在哪里、证据文件是什么、哪些偏移信号需要拦截。如果某一项缺失,状态就不应该被改成 PASS。

实现偏移通常有五类信号。第一,代码改动找不到对应 REQ 或 TRACE,说明实现可能跑出了需求边界。第二,测试只验证函数返回或页面存在,没有验证业务语义。第三,E2E 依赖 mock-only 路径,没有穿过关键集成边界。第四,AI 生成的修复让测试变绿,但删除、放宽或绕开了原有断言。第五,最终报告只有“通过”结论,没有可复查的 evidence artifact。

快速纠偏不应该从“继续让 AI 改代码”开始,而应该从失败证据包开始。最小闭环是:定位失败的 TRACE 行,读取对应 REQ/INV/E2E,确认偏移类型,补最小复现或补断言,再让 AI 只修改与该 TRACE 相关的代码和测试。修复后重新运行对应 gate,并更新 evidence 路径和状态。这个过程能把一次大范围返工压缩成围绕单条需求契约的迭代。

偏移信号风险纠偏动作
有代码无 REQ/TRACE实现跑出需求边界补需求映射或撤回无依据实现
测试无业务断言happy path 伪通过增加业务可见结果断言
E2E 只走 mock集成边界未验证接入 Fake gateway 和真实数据路径
PASS 无 evidence完成状态不可复查生成并记录报告、截图或 JSON 证据
AI 放宽断言测试质量下降恢复原断言并补失败用例

这张表的使用方式很直接:每次 AI 声称完成时,先按偏移信号逐项检查,而不是先读代码 diff。交易系统尤其需要这种纪律,因为很多错误不会在单元测试里暴露,而会在数据路径、交易日语义、策略事件和订单适配器的组合处出现。

第八部分:测试数据管理

测试数据管理决定测试是否可复现。量化系统不能把真实行情直接塞进所有测试,也不能只靠随机生成数据。更稳妥的组合是:合成数据覆盖结构性场景,数据快照保存真实缺陷样本,契约测试保护数据源边界。

合成数据适合覆盖趋势、波动率、缺口、跳空和极值。生成器可以控制起止时间、趋势方向、波动率和成交量范围。

# 示意代码,非实际生产代码
def generate_synthetic_bars(
    symbol: str,
    start: datetime,
    end: datetime,
    trend: str = "random",
    volatility: float = 0.02,
) -> List[BarData]:
    """生成合成 K 线数据"""
    bars = []
    current_time = start
    price = 25000

    while current_time <= end:
        if trend == "up":
            change = abs(random.gauss(0.001, volatility))
        elif trend == "down":
            change = -abs(random.gauss(0.001, volatility))
        else:
            change = random.gauss(0, volatility)

        price *= (1 + change)
        bar = BarData(
            symbol=symbol,
            timestamp=current_time,
            open_price=price * (1 + random.gauss(0, 0.001)),
            high_price=price * (1 + abs(random.gauss(0, 0.002))),
            low_price=price * (1 - abs(random.gauss(0, 0.002))),
            close_price=price,
            volume=random.randint(1000, 10000),
        )
        bars.append(bar)
        current_time += timedelta(minutes=1)

    return bars

这段代码适合构造趋势、波动率和成交量范围,但不应该替代真实缺陷快照。读者应固定随机种子或记录生成参数,否则合成数据本身会成为不稳定来源。

数据快照适合保存真实缺陷样本。脱敏后的 fixtures/hsi_2024_01.json 可以用来复现周五夜盘归属错误或半日市收盘异常。

# 示意代码,非实际生产代码
@pytest.fixture
def real_market_data():
    """使用脱敏后的真实市场数据快照"""
    return load_test_data("fixtures/hsi_2024_01.json")

快照要小而稳定,重点是保留复现缺陷的最小数据。读者应记录来源、脱敏方式、交易日归属规则和对应缺陷编号。

契约测试适合验证数据源边界。例如 get_bars("HSI", "1m", count=100) 返回的数据必须按时间排序;每根 Bar 必须满足 high >= open/close/low;时间戳必须有时区。

# 示意代码,非实际生产代码
def test_datafeed_contract():
    """验证数据源满足基本契约"""
    feed = DataFeed()

    bars = feed.get_bars("HSI", "1m", count=100)
    timestamps = [bar.timestamp for bar in bars]
    assert timestamps == sorted(timestamps)

    for bar in bars:
        assert bar.high >= bar.open
        assert bar.high >= bar.close
        assert bar.high >= bar.low

契约测试能防止上游数据变化悄悄破坏下游策略。读者应把它放在数据入口附近,而不是等策略测试失败后再排查数据问题。

量化交易系统测试数据血缘图
图 4:测试数据血缘图,从原始行情到清洗、归属、聚合、fixture 和断言保持可追踪。

图 4 的重点是血缘。测试数据从原始行情进入清洗、交易日归属、聚合、fixture、断言和回归记录,每一步都应该可追踪。否则测试失败时,读者无法判断是原始数据变化、清洗逻辑变化、交易日归属变化,还是断言本身过期。

第九部分:CI/CD 中的自动化测试

CI Gate 的职责不是跑所有测试,而是在合适的阶段阻断错误合并。不同测试层承担不同反馈速度和风险拦截职责。

量化交易系统 CI Gate 分层地图
图 5:CI Gate 决策图,不同测试层承担不同的反馈速度和风险拦截职责。

一次典型 CI 可以分层执行:单元测试最快,覆盖核心算法;集成测试验证数据源、聚合器、策略和订单模块协作;属性测试用固定 seed 保证可复现;边界时间测试覆盖 HKFE 特殊交易日;覆盖率报告用于防止核心模块失去测试保护;失败测试必须阻止合并。

name: Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-python@v4
        with:
          python-version: "3.10"
      - run: pip install -r requirements.txt -r requirements-test.txt
      - run: pytest tests/unit -v --cov=core --cov-report=xml
      - run: pytest tests/integration -v
      - run: pytest tests/property -v --hypothesis-seed=0
      - uses: codecov/codecov-action@v3
        with:
          files: ./coverage.xml
          fail_ci_if_error: true

这段配置把快速反馈、模块协作、属性测试和覆盖率报告拆成独立步骤。读者应根据项目规模决定哪些测试进入每次 PR,哪些测试进入夜间任务,但失败测试必须能够阻止高风险合并。

测试报告需要能回答“哪些层级已经被验证”。下面的输出示例展示的是报告应该承载的信息,而不是要求读者复制具体数字。

tests/unit/test_aggregation.py PASSED
tests/unit/test_indicators.py PASSED
tests/unit/test_datafeed.py PASSED
tests/unit/test_strategy.py PASSED
tests/integration/test_end_to_end.py PASSED
tests/property/test_properties.py PASSED
tests/boundary/test_hkfe_cases.py PASSED
tests/regression/test_issue_*.py PASSED

core/aggregation.py        97%
core/indicators.py         94%
core/datafeed.py           91%
core/strategy.py           88%
total                      95%

这些数字本身不是目标,但它们让读者知道风险拦截在哪里发生。更重要的是:边界时间、属性测试和真实缺陷回归是否被纳入报告,而不是总覆盖率是否漂亮。

第十部分:TDD 反模式与陷阱

第一个反模式是测试实现而不是行为。测试 internal_cache.size() == 5 会把测试绑死在内部结构上;更稳妥的是断言外部行为。

# 示意代码,非实际生产代码
def test_implementation():
    """错误示例:测试内部实现细节"""
    result = calculate()
    assert result.internal_cache.size() == 5

这段代码的问题是测试依赖内部缓存结构。只要实现换成生成器、数组或增量状态,测试就会失败,即使外部行为没有变化。

# 示意代码,非实际生产代码
def test_behavior():
    """正确示例:测试外部行为"""
    result = calculate([1, 2, 3, 4, 5])
    assert result == 3.0

行为测试给重构保留空间。交易系统需要这种测试,因为性能优化、数据结构替换和执行域拆分都会改变内部实现。

第二个反模式是一个测试验证太多东西。一个测试同时调用 func1func2func3,失败时很难定位根因。

# 示意代码,非实际生产代码
def test_everything():
    """错误示例:一个测试覆盖所有功能"""
    result1 = func1()
    result2 = func2()
    result3 = func3()
    assert result1 == expected1
    assert result2 == expected2
    assert result3 == expected3

这段代码的问题是失败定位困难。读者无法快速判断是输入准备、某个函数行为,还是前置状态污染了后续断言。

# 示意代码,非实际生产代码
def test_func1():
    """正确示例:只测试 func1"""
    assert func1() == expected1

def test_func2():
    """正确示例:只测试 func2"""
    assert func2() == expected2

拆分后的测试让失败信号更清楚。量化系统中尤其要避免一个测试同时验证数据加载、指标计算、策略信号和订单执行。

第三个反模式是忽略边界情况。只测试 divide(10, 2) == 5 不足以证明函数可靠;还需要测试除零、小数、极小数和浮点近似。

# 示意代码,非实际生产代码
def test_normal_case():
    """错误示例:只测试正常情况"""
    assert divide(10, 2) == 5

这段测试只覆盖 happy path。交易系统里的边界更复杂,必须覆盖开盘、收盘、午休、夜盘、半日市和跨周。

# 示意代码,非实际生产代码
def test_normal_case():
    assert divide(10, 2) == 5

def test_divide_by_zero():
    """测试除零边界"""
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)

def test_small_numbers():
    """测试浮点近似"""
    assert divide(1, 3) == pytest.approx(0.333, rel=1e-3)

边界测试的价值是把“不常发生但一旦发生就影响巨大”的情况固定下来。量化系统里,很多真实事故都出现在低频边界,而不是日常路径。

第四个反模式是测试数据没有血缘。测试失败时如果无法知道 fixture 来自哪里、经过哪些清洗和归属逻辑,就很难判断失败是否有效。所有真实缺陷样本都应该记录来源、脱敏方式、归属规则和断言目的。

第五个反模式是把 AI 生成测试当成验收结果。AI 可以扩展场景,但不能证明场景代表真实业务风险。验收权仍然来自规格、缺陷复现、人工 Review 和 CI Gate。

总结:测试 checklist

测试设计需要确认:

  • 是否从真实缺陷和风险族群反推测试,而不是只追求覆盖率。
  • 是否覆盖正常、边界、异常三类场景。
  • 是否使用属性测试发现隐藏假设。
  • 是否有针对量化场景的边界时间测试。

测试实现需要确认:

  • 测试是否独立,不依赖执行顺序。
  • 测试是否足够快,常规单测应保持秒级反馈。
  • 测试是否可读,一眼能看出验证什么。
  • 是否有清晰的 Arrange-Act-Assert 或 Given-When-Then 结构。

测试替身需要确认:

  • 外部依赖是否使用 Stub、Mock、Spy 或 Fake 隔离。
  • 复杂对象是否有简化但真实可用的 Fake。
  • 交互验证是否只在确实需要时使用 Mock。

测试数据需要确认:

  • 是否使用合成数据保证可重复。
  • 是否保留真实缺陷的脱敏快照。
  • 是否有数据契约测试保护上游边界。
  • 敏感数据是否脱敏。

CI Gate 需要确认:

  • 每次提交是否运行关键测试。
  • 核心模块覆盖率是否高于约定阈值。
  • 失败测试是否阻止合并。
  • 回归缺陷是否转化为固定测试。

下一篇预告

下一篇进入 Python 性能调优实战。读者会看到 profiler 如何定位瓶颈,增量计算和虚拟化渲染如何降低延迟,以及为什么性能优化必须保留正确性证据。

参考资源

Series context

你正在阅读:量化交易系统开发实录

当前为第 4 / 7 篇。阅读进度只写入此浏览器的 localStorage,用于回到系列页时定位继续阅读入口。

查看完整系列 →

Series Path

当前系列章节

点击章节会在此浏览器记录本地阅读进度;刷新后可继续阅读。

7 chapters
  1. Part 1 已在路径前序 量化交易系统开发实录(一):项目启动与架构设计的五个关键决策 以 Micang Trader 为案例,从系统边界、数据流、交易时段归属、回测实盘统一接口和 AI 协作边界出发,建立整个量化交易系统系列的架构主线。
  2. Part 2 已在路径前序 量化交易系统开发实录(二):Python Pitfalls 实战避坑指南(上) 把 Python 陷阱从长清单重组为量化交易系统的工程风险参考篇:语法与作用域、类型与状态、并发与状态三类风险如何放大为真实交易系统问题。
  3. Part 3 已在路径前序 量化交易系统开发实录(三):Python Pitfalls 实战避坑指南(下) 继续把 Python 风险重组为参考篇:GUI 生命周期、异步网络失败、安全边界和部署基础设施如何影响量化交易系统的长期稳定性。
  4. Part 4 当前阅读 量化交易系统开发实录(四):测试驱动敏捷开发(AI Agent 辅助) 从一个跨夜交易日边界 bug 出发,重构量化交易系统的测试防线:缺陷导向测试金字塔、AI TDD 分工、边界时间、数据血缘和 CI Gate。
  5. Part 5 量化交易系统开发实录(五):Python 性能调优实战 把性能优化从经验猜测改造成可验证的侦查流程:从 3 秒图表延迟出发,定位真实瓶颈,比较优化方案,建立 benchmark 与回退策略。
  6. Part 6 量化交易系统开发实录(六):架构演进与重构决策 复盘 Micang Trader 的五次重构,解释系统如何从初始快照演进为更清晰的目标架构,并把技术债务和 ADR 决策纳入长期治理。
  7. Part 7 量化交易系统开发实录(七):AI 工程化落地——从 speckit 到 BMAD 以交易日历与日线聚合需求为单一案例,解释 AI 工程化如何通过规格驱动、BMAD 角色交接和人工质量门禁进入真实量化系统交付。

Reading path

继续沿这条专题路径阅读

按推荐顺序继续阅读 量化系统开发实战 相关内容,而不是只看同专题的随机文章。

查看完整专题路径 →

Next step

继续深入这个专题

如果这篇内容对你有帮助,下一步可以回到专题页继续系统阅读,或者订阅后续更新。

返回专题页 订阅 RSS 更新

RSS Subscribe

订阅更新

通过 RSS 阅读器订阅获取最新文章推送,无需频繁访问网站。

推荐使用 FollowFeedlyInoreader 等 RSS 阅读器

评论与讨论

使用 GitHub 账号登录参与讨论,评论将同步至 GitHub Discussions

正在加载评论...