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 线数据在入库前,至少还需要过五道检查:
- 身份:返回的
symbol、interval是否与请求一致; - 结构:必填字段是否齐全,
klines是否为数组; - 时间:时间戳是否为合法正毫秒整数,批内是否有重复;
- 数值:所有 OHLC 和成交量字段能否解析为有限 Decimal;
- 行内关系:
low <= high且open、close在高低区间内。
四层成功定义:
- 传输成功: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=0 且 klines=[]。空数组是否可接受取决于业务场景:回测可能视其为数据缺口,展示则可能视为“暂无数据”。校验器不替调用方做决定,通过 allow_empty 参数强制显式选择:
allow_empty=False:空数组抛出PayloadValidationError;allow_empty=True:空数组返回空列表。
未传入该参数将导致函数拒绝执行。
2.3 时间字段:严格拒绝布尔型
Python 中 isinstance(True, int) 为 True,若只做 isinstance(value, int) 检查,True 和 False 会分别变成 1 和 0 并静默通过。校验器使用 type(value) is int 严格匹配,且要求 value > 0。True、False、0、负数均触发 PayloadValidationError。
2.4 数值字段:必须为字符串 + Decimal + is_finite
所有价格和成交量字段必须先检查为 str 类型,非字符串值立即抛错。之后使用 Decimal(value) 解析,并检查 is_finite()。不满足则抛错。缺失字段不会默认为 Decimal("0")——缺失就是缺失,零是零,二者不可混淆。
2.5 行内关系
每根 K 线必须满足:
low <= highlow <= open <= highlow <= 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 变成 1、False 变成 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 入库

浙公网安备 33010602011771号