Files
easy-tdx/src/easy_tdx/_df.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

239 lines
8.4 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.
"""Dataclass → DataFrame 转换工具。"""
from __future__ import annotations
import logging
from dataclasses import asdict, is_dataclass, replace
from typing import Any
import pandas as pd
logger = logging.getLogger(__name__)
# K 线时间戳语义:通达信用 bar 开始时间,Tushare/同花顺用 bar 结束时间。
# bar_time="end" 时给分钟级 bar 的时刻加上一个周期时长,以对齐 Tushare。
_BAR_TIME_START = "start"
_BAR_TIME_END = "end"
# A 股 / 扩展行情 KlineCategory → 每根 bar 的分钟数(分钟级;日线及以上不在此表)。
# category: 0=MIN_5 1=MIN_15 2=MIN_30 3=MIN_60 7=MIN_1 8=MIN_3。
_CATEGORY_MINUTES: dict[int, int] = {0: 5, 1: 15, 2: 30, 3: 60, 7: 1, 8: 3}
_VALID_BAR_TIMES = (_BAR_TIME_START, _BAR_TIME_END)
def _check_bar_time(bar_time: str) -> None:
"""校验 bar_time 取值,非法值立即抛错(fail-fast),避免静默按 "end" 处理。"""
if bar_time not in _VALID_BAR_TIMES:
raise ValueError(
f"bar_time 必须是 {_BAR_TIME_START!r}{_BAR_TIME_END!r},得到: {bar_time!r}"
)
def _category_to_minutes(category: int) -> int | None:
"""分钟级 KlineCategory → 每根 bar 的分钟数;日线及以上返回 None。"""
return _CATEGORY_MINUTES.get(int(category))
def _period_to_minutes(period: int, times: int = 1) -> int | None:
"""MAC 协议 Period → 每根 bar 的分钟数。
MINS / SECONDS 配合 times 倍数;日线及以上 / 秒级(按分钟粒度对齐无意义)返回 None。
"""
# 与 symbol_bar.py 的 is_intraday 判定保持一致
_MAC_INTRADAY_MINUTES: dict[int, int] = {
0: 5, # MIN_5
1: 15, # MIN_15
2: 30, # MIN_30
3: 60, # MIN_60
7: 1, # MIN_1
8: 5, # MINS(×times
}
base = _MAC_INTRADAY_MINUTES.get(int(period))
if base is None:
# 4=DAILY 5=WEEKLY 6=MONTHLY 9=DAYS 10=QUARTERLY 11=YEARLY 13=SECONDS 均不偏移
return None
if int(period) == 8: # MINS:多分钟,乘以倍数
return base * max(int(times), 1)
return base
def _to_df(data: Any) -> pd.DataFrame:
"""将 list[dataclass] 或单个 dataclass 转为 DataFrame。
自动丢弃以 ``_`` 开头的内部字段(如 ``_raw``)。
仅处理 year/month/day(无 hour/minute)→ date 的合并;
SecurityBar 的完整 datetime 合并由调用方按周期决定。
"""
if isinstance(data, list):
if not data:
return pd.DataFrame()
rows = []
for item in data:
d = _clean_dict(item)
rows.append(d)
return pd.DataFrame(rows)
if is_dataclass(data) and not isinstance(data, type):
return pd.DataFrame([_clean_dict(data)])
raise TypeError(f"不支持转换为 DataFrame 的类型: {type(data)}")
def _clean_dict(item: Any) -> dict[str, Any]:
d = asdict(item)
d = {k: v for k, v in d.items() if not k.startswith("_")}
return _merge_datetime_fields(d)
def _merge_datetime_fields(d: dict[str, Any]) -> dict[str, Any]:
"""将仅含 year/month/day(无 hour/minute)的模型合并为 date 列。"""
if all(k in d for k in ("year", "month", "day")) and not all(
k in d for k in ("hour", "minute")
):
dt = pd.Timestamp(year=d["year"], month=d["month"], day=d["day"])
result: dict[str, Any] = {"date": dt}
result.update({k: v for k, v in d.items() if k not in {"year", "month", "day"}})
return result
return d
def _align_minutes_df(df: pd.DataFrame, delta_minutes: int) -> pd.DataFrame:
"""对含 hour/minute 列的 DataFrame 做分钟级偏移(向量化,自动跨小时/跨日)。
用于 A 股 / 扩展行情路径:在 _merge_bar_datetime 拼字符串之前修正 hour/minute。
"""
total = df["hour"] * 60 + df["minute"] + delta_minutes
df = df.copy()
df["hour"] = (total // 60) % 24
df["minute"] = total % 60
return df
def _align_datetime_df(df: pd.DataFrame, delta_minutes: int) -> pd.DataFrame:
"""对含 datetime 列的 DataFrame 做分钟级偏移(MAC 路径用)。"""
if "datetime" not in df.columns:
return df
df = df.copy()
df["datetime"] = df["datetime"] + pd.Timedelta(minutes=delta_minutes)
return df
def _apply_bar_time_align_df(
df: pd.DataFrame,
*,
is_intraday: bool,
delta_minutes: int | None,
bar_time: str,
has_time_columns: bool,
) -> pd.DataFrame:
"""对 K 线 DataFrame 应用 bar 时间对齐。
Args:
is_intraday: 是否分钟级周期(False 时恒不偏移)。
delta_minutes: 每根 bar 的分钟数(None 或分钟级判定为 False 时不偏移)。
bar_time: "start"(默认,通达信原始)或 "end"(右端点,对齐 Tushare)。
has_time_columns: True=DataFrame 仍是分散的 hour/minute 列(A 股路径,
在 _merge_bar_datetime 之前调用);False=已是 datetime 列(MAC 路径)。
"""
if bar_time == _BAR_TIME_START:
return df
_check_bar_time(bar_time)
if not is_intraday or delta_minutes is None or delta_minutes <= 0:
return df
if df.empty:
return df
if has_time_columns:
if "hour" not in df.columns or "minute" not in df.columns:
return df
return _align_minutes_df(df, delta_minutes)
return _align_datetime_df(df, delta_minutes)
def _apply_bar_time_align_bars(
bars: list[Any],
*,
is_intraday: bool,
delta_minutes: int | None,
bar_time: str,
) -> list[Any]:
"""对 K 线 dataclass 列表应用 bar 时间对齐(扩展行情 ex client 用,返回 dataclass)。
用 dataclasses.replace 重建(保持 dataclass 不可变语义),跨小时自动进位;
收盘 bar 不会跨日,故不处理跨日。
"""
if bar_time == _BAR_TIME_START:
return bars
_check_bar_time(bar_time)
if not is_intraday or delta_minutes is None or delta_minutes <= 0:
return bars
result: list[Any] = []
for b in bars:
total = b.hour * 60 + b.minute + delta_minutes
result.append(replace(b, hour=(total // 60) % 24, minute=total % 60))
return result
def _merge_bar_datetime(df: pd.DataFrame, daily_plus: bool) -> pd.DataFrame:
"""根据 K 线周期将 SecurityBar 的分散字段合并为 date 或 datetime。
Args:
daily_plus: True 表示日线及以上周期(DAY/WEEK/MONTH/YEAR),只保留 date
False 表示分钟线(MIN_1/5/15/30/60),保留完整 datetime。
"""
if df.empty or "year" not in df.columns:
return df
date_str = (
df["year"].astype(str)
+ "-"
+ df["month"].astype(str).str.zfill(2)
+ "-"
+ df["day"].astype(str).str.zfill(2)
)
if daily_plus:
df.insert(0, "date", pd.to_datetime(date_str))
else:
full_str = (
date_str
+ " "
+ df["hour"].astype(str).str.zfill(2)
+ ":"
+ df["minute"].astype(str).str.zfill(2)
)
df.insert(0, "datetime", pd.to_datetime(full_str))
df.drop(columns=["year", "month", "day", "hour", "minute"], inplace=True)
return df
def _merge_txn_datetime(df: pd.DataFrame, date_int: int) -> pd.DataFrame:
"""将逐笔成交的 date + hour:minute 合并为 datetime 列。"""
if df.empty or "hour" not in df.columns:
return df
year = date_int // 10000
month = (date_int // 100) % 100
day = date_int % 100
base = pd.Timestamp(year=year, month=month, day=day)
offsets = pd.to_timedelta(df["hour"] * 3600 + df["minute"] * 60, unit="s")
df.insert(0, "datetime", base + offsets)
df.drop(columns=["hour", "minute"], inplace=True)
return df
def _add_minute_datetime(df: pd.DataFrame, date_int: int) -> pd.DataFrame:
"""为分时 DataFrame 添加 datetime 列(从 bar 索引计算时间)。
A 股分时 240 条:0-119 = 9:30~11:29(上午),120-239 = 13:00~14:59(下午)。
"""
if df.empty:
return df
year = date_int // 10000
month = (date_int // 100) % 100
day = date_int % 100
base = pd.Timestamp(year=year, month=month, day=day)
n = len(df)
morning = list(range(9 * 60 + 30, 9 * 60 + 30 + 120))
afternoon = list(range(13 * 60, 13 * 60 + 120))
all_minutes = (morning + afternoon)[:n]
offsets = pd.to_timedelta(all_minutes, unit="m")
df.insert(0, "datetime", base + offsets)
return df