K线 JSON 入库前如何做 Contract Check:Python 校验器与四项 unittest

摘要:K 线 JSON 入库前,调用成功不等同于数据可提交。本文给出一个 Python 校验器,对 K 线 payload 依次执行身份、结构、时间、数值和行内关系五道检查,用 Decimal 解析所有价格字段,严格拒绝布尔型时间戳,并通过 allow_empty 参数强制调用方显式选择空数组策略。配套四项单元测试覆盖正常、重复时间、缺失 close 与 time=True 四种场景。关键词:K线数据校验、Contract Check、行情 JSON 入库、Decimal、重复时间。


一、调用成功 ≠ 数据可提交

一个常见的工程误区是把 HTTP 200 和 code=0 直接当成“数据没问题”。实际链路中,它们只证明服务端接受了请求并返回了响应。K 线数据在入库前,至少还需要过五道检查:

  • 身份:返回的 symbolinterval 是否与请求一致;
  • 结构:必填字段是否齐全,klines 是否为数组;
  • 时间:时间戳是否为合法正毫秒整数,批内是否有重复;
  • 数值:所有 OHLC 和成交量字段能否解析为有限 Decimal;
  • 行内关系low <= highopenclose 在高低区间内。

四层成功定义

  • 传输成功:HTTP 200,响应体可解析为 JSON。
  • 业务成功code=0,服务端表示请求已受理。
  • 结构成功data 为 dict,symbol/interval 与请求一致,klines 为 list,每行必填字段齐全。
  • 可提交:结构成功 + 整批通过时间类型、Decimal finite、重复检测和 OHLC 关系校验。

validate_kline_payload 只处理已解析的 payload,不检查 HTTP 状态或 JSON 解析——这些属于调用层职责。本文也不涉及 HTTP 请求和数据库落库,只聚焦这一段独立功能:已拿到 K 线 payload,如何用显式规则判定它能否通过合同检查。


二、校验器设计

2.1 异常类型

所有校验失败均抛出 PayloadValidationError,携带具体错误描述。调用方只需捕获这一种异常即可统一处理坏批次,不暴露底层实现细节。

2.2 空数组策略

API 在某些条件下可能返回 code=0klines=[]。空数组是否可接受取决于业务场景:回测可能视其为数据缺口,展示则可能视为“暂无数据”。校验器不替调用方做决定,通过 allow_empty 参数强制显式选择:

  • allow_empty=False:空数组抛出 PayloadValidationError
  • allow_empty=True:空数组返回空列表。

未传入该参数将导致函数拒绝执行。

2.3 时间字段:严格拒绝布尔型

Python 中 isinstance(True, int)True,若只做 isinstance(value, int) 检查,TrueFalse 会分别变成 10 并静默通过。校验器使用 type(value) is int 严格匹配,且要求 value > 0TrueFalse0、负数均触发 PayloadValidationError

2.4 数值字段:必须为字符串 + Decimal + is_finite

所有价格和成交量字段必须先检查为 str 类型,非字符串值立即抛错。之后使用 Decimal(value) 解析,并检查 is_finite()。不满足则抛错。缺失字段不会默认为 Decimal("0")——缺失就是缺失,零是零,二者不可混淆。

2.5 行内关系

每根 K 线必须满足:

  • low <= high
  • low <= open <= high
  • low <= close <= high

违反任意一项即抛 PayloadValidationError


三、完整代码

以下代码为 Python 3.11 独立模块,只处理已获取的 payload,不发送 HTTP 请求,不连接数据库。

"""
kline_contract_check.py
K 线 JSON 入库前的 Contract Check。
Python 3.11+
"""

from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from typing import List


class PayloadValidationError(Exception):
    """K 线 payload 校验失败。"""
    pass


@dataclass
class KLine:
    """单根 K 线,所有数值字段为 Decimal。"""
    time: int
    open: Decimal
    high: Decimal
    low: Decimal
    close: Decimal
    volume: Decimal
    quote_volume: Decimal


def validate_kline_payload(
    payload: dict,
    requested_symbol: str,
    requested_interval: str,
    allow_empty: bool
) -> List[KLine]:
    """
    校验 K 线 payload,返回按 time 升序排列的 KLine 列表。

    参数:
        payload: API 返回的完整 JSON 字典。
        requested_symbol: 请求时使用的 symbol,如 "AAPL.US"。
        requested_interval: 请求时使用的 interval,如 "1d"。
        allow_empty: True 时允许 klines 为空数组,False 时空数组抛错。
                    调用方必须显式选择策略。

    返回:
        List[KLine]: 按 time 升序排列的 K 线列表。

    异常:
        PayloadValidationError: 任意校验规则不通过。
    """

    # 1. 顶层结构
    if not isinstance(payload, dict):
        raise PayloadValidationError("payload 不是 dict")
    if payload.get("code") != 0:
        raise PayloadValidationError(f"code 不为 0: {payload.get('code')}")

    # 2. data 结构
    data = payload.get("data")
    if not isinstance(data, dict):
        raise PayloadValidationError("data 不是 dict")

    # 3. symbol / interval 身份
    actual_symbol = data.get("symbol")
    if actual_symbol != requested_symbol:
        raise PayloadValidationError(
            f"symbol 不一致: 期望 {requested_symbol},实际 {actual_symbol}"
        )
    actual_interval = data.get("interval")
    if actual_interval != requested_interval:
        raise PayloadValidationError(
            f"interval 不一致: 期望 {requested_interval},实际 {actual_interval}"
        )

    # 4. klines 数组
    klines = data.get("klines")
    if not isinstance(klines, list):
        raise PayloadValidationError("klines 不是 list")

    if len(klines) == 0:
        if allow_empty:
            return []
        else:
            raise PayloadValidationError("klines 为空数组且 allow_empty=False")

    # 5. 逐行校验
    REQUIRED = {"time", "open", "high", "low", "close", "volume", "quote_volume"}
    result: List[KLine] = []
    seen_times: set = set()

    for i, row in enumerate(klines):
        if not isinstance(row, dict):
            raise PayloadValidationError(f"klines[{i}] 不是 dict")

        # 必填字段存在性
        missing = REQUIRED - row.keys()
        if missing:
            raise PayloadValidationError(f"klines[{i}] 缺少字段: {', '.join(sorted(missing))}")

        # 时间字段:严格拒绝 bool
        raw_time = row["time"]
        if type(raw_time) is not int or raw_time <= 0:
            raise PayloadValidationError(
                f"klines[{i}].time 必须为正整数 int,实际: {type(raw_time).__name__}={raw_time}"
            )

        # 批内重复时间
        if raw_time in seen_times:
            raise PayloadValidationError(f"klines[{i}].time 重复: {raw_time}")
        seen_times.add(raw_time)

        # 数值字段:必须先为 str,再解析为 Decimal + is_finite
        for field_name in ("open", "high", "low", "close", "volume", "quote_volume"):
            raw_val = row[field_name]
            if not isinstance(raw_val, str):
                raise PayloadValidationError(
                    f"klines[{i}].{field_name} 必须为字符串,实际: {type(raw_val).__name__}"
                )
        try:
            open_val = Decimal(row["open"])
            high_val = Decimal(row["high"])
            low_val = Decimal(row["low"])
            close_val = Decimal(row["close"])
            volume_val = Decimal(row["volume"])
            quote_volume_val = Decimal(row["quote_volume"])
        except (InvalidOperation, ValueError) as e:
            raise PayloadValidationError(f"klines[{i}] 数值字段解析失败: {e}")

        for field_name, val in [
            ("open", open_val), ("high", high_val), ("low", low_val),
            ("close", close_val), ("volume", volume_val), ("quote_volume", quote_volume_val)
        ]:
            if not val.is_finite():
                raise PayloadValidationError(
                    f"klines[{i}].{field_name} 为非有限数值: {val}"
                )

        # 行内关系
        if low_val > high_val:
            raise PayloadValidationError(
                f"klines[{i}] low ({low_val}) > high ({high_val})"
            )
        if not (low_val <= open_val <= high_val):
            raise PayloadValidationError(
                f"klines[{i}] open ({open_val}) 不在 [{low_val}, {high_val}] 区间"
            )
        if not (low_val <= close_val <= high_val):
            raise PayloadValidationError(
                f"klines[{i}] close ({close_val}) 不在 [{low_val}, {high_val}] 区间"
            )

        result.append(KLine(
            time=raw_time,
            open=open_val,
            high=high_val,
            low=low_val,
            close=close_val,
            volume=volume_val,
            quote_volume=quote_volume_val,
        ))

    # 6. 按 time 排序
    result.sort(key=lambda k: k.time)
    return result

四、四项单元测试

以下测试使用标准库 unittest,所有 payload 为最小构造数据,不冒充实时行情。

"""
test_kline_contract_check.py
K 线校验器单元测试——四项最小覆盖。
Python 3.11+
"""

import unittest
from kline_contract_check import validate_kline_payload, PayloadValidationError, KLine
from decimal import Decimal


class TestValidateKlinePayload(unittest.TestCase):

    def _base_payload(self, klines):
        """构造基础 payload 骨架。"""
        return {
            "code": 0,
            "message": "success",
            "data": {
                "symbol": "AAPL.US",
                "interval": "1d",
                "klines": klines
            }
        }

    # 1. 正常 payload
    def test_valid_single_kline(self):
        """正常 payload 应返回一根 KLine。"""
        payload = self._base_payload([{
            "time": 1749398400000,
            "open": "200.00",
            "high": "205.00",
            "low": "199.00",
            "close": "204.00",
            "volume": "1000",
            "quote_volume": "204000.00"
        }])
        result = validate_kline_payload(payload, "AAPL.US", "1d", allow_empty=False)
        self.assertEqual(len(result), 1)
        self.assertIsInstance(result[0], KLine)
        self.assertEqual(result[0].time, 1749398400000)
        self.assertEqual(result[0].open, Decimal("200.00"))

    # 2. 重复 time
    def test_duplicate_time_raises(self):
        """两条 K 线 time 相同时必须抛错。"""
        payload = self._base_payload([
            {"time": 1749398400000, "open": "200", "high": "205", "low": "199",
             "close": "204", "volume": "1000", "quote_volume": "204000"},
            {"time": 1749398400000, "open": "204", "high": "206", "low": "203",
             "close": "205", "volume": "1200", "quote_volume": "246000"}
        ])
        with self.assertRaises(PayloadValidationError) as ctx:
            validate_kline_payload(payload, "AAPL.US", "1d", allow_empty=False)
        self.assertIn("重复", str(ctx.exception))

    # 3. 缺失 close
    def test_missing_close_raises(self):
        """缺少必填字段 close 时必须抛错。"""
        payload = self._base_payload([{
            "time": 1749398400000,
            "open": "200",
            "high": "205",
            "low": "199",
            "volume": "1000",
            "quote_volume": "204000"
        }])
        with self.assertRaises(PayloadValidationError) as ctx:
            validate_kline_payload(payload, "AAPL.US", "1d", allow_empty=False)
        self.assertIn("缺少字段", str(ctx.exception))
        self.assertIn("close", str(ctx.exception))

    # 4. time=True
    def test_time_true_raises(self):
        """time=True 时必须抛错(布尔型不能静默通过)。"""
        payload = self._base_payload([{
            "time": True,
            "open": "200",
            "high": "205",
            "low": "199",
            "close": "204",
            "volume": "1000",
            "quote_volume": "204000"
        }])
        with self.assertRaises(PayloadValidationError) as ctx:
            validate_kline_payload(payload, "AAPL.US", "1d", allow_empty=False)
        self.assertIn("time", str(ctx.exception).lower())


if __name__ == "__main__":
    unittest.main()

正常 payload 用例预期返回 KLine;重复 time、缺失 close、time=True 三项预期抛出 PayloadValidationError,并检查相应错误信息。


五、应用层与数据库层职责分工

本文使用的校验规则按职责划分为应用层验证和数据库层约束。

校验规则 应用层(Python) 数据库层(PostgreSQL 示例)
symbol/interval 与请求一致 ✓ 逐次校验
必填字段齐全 ✓ 逐行校验 NOT NULL
数值为合法 Decimal 且有限 ✓ Decimal + is_finite 有限值需单独设计显式 CHECK,本文不展开具体 SQL
批内 time 无重复 ✓ 集合去重 UNIQUE(symbol, interval, time) 要求相关列为 NOT NULL
low <= high, open/close 在区间内 ✓ 逐行校验 CHECK
空数组策略 ✓ allow_empty 显式选择

本文表格列出的 NOT NULL、UNIQUE 和行级 CHECK 约束不负责发现跨行时间缺口,不检测调用方请求的 symbol 是否与返回一致,不判断空数组的业务合理性。这些示例不代表 PostgreSQL 全部约束能力。


六、部署前检查清单


七、FAQ

Q1:空数组到底该报错还是放行?
取决于调用方。回测场景中空数组可能代表数据缺口,应报错;展示场景中可能是合理的“暂无数据”。因此 allow_empty 不设默认值,调用方必须显式选择。

Q2:为什么时间字段要严格拒绝 bool?
Python 中 isinstance(True, int)True,只用 isinstance 检查会使 True 变成 1False 变成 0 静默通过。type(value) is int 精确排除了布尔型。

Q3:为什么价格用 Decimal 而不是 float?
float 是二进制浮点数,0.1 + 0.2 不等于 0.3。Decimal 以十进制存储,适合金融数据的精确校验。

Q4:数据库有 UNIQUE 约束了,为什么应用层还要检测批内重复时间?
数据库约束在 INSERT 时触发,此时坏数据已试图落库。应用层提前检测可更早定位错误行,避免无效数据库往返,两者互补而非替代。


你在 K 线数据入库前遇到过最隐蔽的错误是什么——重复时间戳、OHLC 关系异常,还是 bool 静默转换成 1?评论区聊聊。


标签:K线数据校验 / Contract Check / Decimal / OHLC 约束 / Python 数据验证 / 行情 JSON 入库

posted @ 2026-06-10 09:38  Walter先生  阅读(6)  评论(0)    收藏  举报