Files
easy-tdx/src/easy_tdx/offline/write_daily.py
T
GitHub 155328df8b release: v1.16.2 — 三轮审计质量加固(B6.9→A7.9)
经三轮代码审计后的综合质量加固版本,覆盖协议核心层、数据正确性、
错误处理、测试真实度与可维护性。761 单测全绿(+58),ruff/mypy 全过。

主要修复:
- 离线 .day 写入原子化(fsync + _repair_tail + 读取校验,CQS 守住)
- 回测止损前视偏差(延迟下一根开盘 + 跳空保护)
- VWAP 权重索引 / bar_time fail-fast / 绩效除零保护
- 闭包绑定 / 路径穿越 / naive datetime 跨时区 / ruff UP038

重构:
- 抽 AsyncHeartbeatMixin 收敛 4 处心跳副本(12→1)
- 统一 _RETRY_DELAYS 退避序列 / scanner 失败可观测性

新增 5 个测试文件 + 公共 API 类型契约,CI 加 Windows 矩阵 +
trusted publishing 签名 + 锁文件。

详见 CHANGELOG.md
2026-07-02 03:37:37 +08:00

195 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""离线日线数据写入 —— 将 SecurityBar 编码并追加到 .day 文件。"""
from __future__ import annotations
import logging
import os
from pathlib import Path
from ..models.bar import SecurityBar
from .daily_bar import _DAILY_FMT
__all__ = [
"encode_daily_bar",
"append_daily_bars",
"get_last_bar_date",
"sync_daily_bars_from_security_bars",
]
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# encode
# ---------------------------------------------------------------------------
def encode_daily_bar(
bar: SecurityBar,
price_coeff: float,
vol_coeff: float,
) -> bytes:
"""将 SecurityBar 编码为 32 字节 .day 记录。
Args:
bar: K 线数据(open/close/high/low 为实际价格,非整数)。
price_coeff: 价格系数(A 股 0.01,基金 0.001 等)。
vol_coeff: 成交量系数(A 股 0.01,指数 1.0 等)。
Returns:
32 字节的二进制记录。
"""
date_int = bar.year * 10000 + bar.month * 100 + bar.day
return _DAILY_FMT.pack(
date_int,
int(round(bar.open / price_coeff)),
int(round(bar.high / price_coeff)),
int(round(bar.low / price_coeff)),
int(round(bar.close / price_coeff)),
bar.amount, # float32, 由 struct 自动截断
int(round(bar.vol / vol_coeff)),
0, # reserved
)
# ---------------------------------------------------------------------------
# query
# ---------------------------------------------------------------------------
def get_last_bar_date(filepath: str | Path) -> int | None:
"""读取 .day 文件最后一条完整记录的日期(纯读,无副作用)。
若文件尾部存在不完整记录(size 非 32 的整数倍,通常由上次写入中途崩溃/
断电导致),只告警并跳过损坏尾部,返回最后一条完整记录的日期——
不修改文件(遵守 command-query separation"get" 不应写)。
损坏尾部的清理由 :func:`_repair_tail` 在写入路径统一完成。
Returns:
YYYYMMDD 整数,文件为空/太短/无完整记录时返回 None。
"""
filepath = Path(filepath)
if not filepath.is_file():
return None
size = filepath.stat().st_size
if size < _DAILY_FMT.size:
return None
# 完整性检查:非整数倍说明尾部有半条损坏记录,跳过它读最后一条完整记录。
remainder = size % _DAILY_FMT.size
if remainder != 0:
logger.warning(
"%s 大小 %d 不是 %d 的整数倍,尾部 %d 字节为损坏记录,"
"将读取最后一条完整记录(文件未修改,写入时由 _repair_tail 清理)",
filepath,
size,
_DAILY_FMT.size,
remainder,
)
size -= remainder
if size < _DAILY_FMT.size:
return None
with filepath.open("rb") as f:
f.seek(size - _DAILY_FMT.size)
last_record = f.read(_DAILY_FMT.size)
(date_int, *_) = _DAILY_FMT.unpack(last_record)
return int(date_int)
def _repair_tail(filepath: Path) -> None:
"""截断文件尾部的损坏记录(非整数倍 32 字节的残余)。
仅在写入路径调用,保证 get_last_bar_date 这类查询函数无副作用(审计 #1)。
"""
if not filepath.is_file():
return
size = filepath.stat().st_size
remainder = size % _DAILY_FMT.size
if remainder != 0:
logger.warning(
"%s 尾部 %d 字节为损坏记录,写入前截断到最后一条完整记录",
filepath,
remainder,
)
with filepath.open("r+b") as f:
f.truncate(size - remainder)
def _bar_date_int(bar: SecurityBar) -> int:
return bar.year * 10000 + bar.month * 100 + bar.day
# ---------------------------------------------------------------------------
# append
# ---------------------------------------------------------------------------
def append_daily_bars(
filepath: str | Path,
bars: list[SecurityBar],
price_coeff: float,
vol_coeff: float,
) -> int:
"""将 bars 追加写入 .day 文件,自动跳过重复日期。
Args:
filepath: .day 文件路径。
bars: 待写入的 K 线列表(按时间升序)。
price_coeff: 价格系数。
vol_coeff: 成交量系数。
Returns:
实际写入的记录数。
"""
filepath = Path(filepath)
# 写入前清理上次崩溃可能残留的尾部半条记录(审计 #1)
_repair_tail(filepath)
# 获取文件末尾日期,用于去重
last_date = get_last_bar_date(filepath)
# 过滤出日期严格大于末尾的新记录
new_bars = (
[b for b in bars if _bar_date_int(b) > last_date] if last_date is not None else list(bars)
)
if not new_bars:
return 0
encoded = b"".join(encode_daily_bar(b, price_coeff, vol_coeff) for b in new_bars)
with filepath.open("ab") as f:
f.write(encoded)
# flush + fsync 确保落盘,避免进程崩溃/断电导致文件尾部残留半条记录
# (32 字节记录的非原子追加会损坏 get_last_bar_date 的去重依据)。
f.flush()
os.fsync(f.fileno())
return len(new_bars)
# ---------------------------------------------------------------------------
# sync
# ---------------------------------------------------------------------------
def sync_daily_bars_from_security_bars(
filepath: str | Path,
server_bars: list[SecurityBar],
price_coeff: float,
vol_coeff: float,
) -> int:
"""将服务端获取的日线数据同步写入本地 .day 文件。
完整流程:读取文件末尾日期 → 过滤新数据 → 追加写入。
Args:
filepath: .day 文件路径。
server_bars: 服务端返回的日线数据(按时间升序)。
price_coeff: 价格系数。
vol_coeff: 成交量系数。
Returns:
实际写入的记录数。
"""
return append_daily_bars(filepath, server_bars, price_coeff, vol_coeff)