feat: add offline data write-back and sync commands, bump to v1.6.0

- Add write_daily.py: encode/append daily bars to .day files
- Add write_ex_daily.py: encode/append extended market daily bars
- Add write_min_bar.py: encode/append minute bars (.5/.lc1/.lc5)
- Add sync-daily CLI: sync single stock with pagination support
- Add sync-all CLI: one-command sync for all SH/SZ .day files
- Update README with sync commands and Python write API docs
- 50 new unit tests covering encode round-trip, append dedup, edge cases
- Bump version 1.5.0 -> 1.6.0
This commit is contained in:
Justin Gu
2026-06-07 21:13:49 +08:00
parent b17e98468b
commit d01b11fa74
10 changed files with 1583 additions and 12 deletions
+53 -3
View File
@@ -3,7 +3,7 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![PyPI](https://img.shields.io/pypi/v/easy-tdx.svg)](https://pypi.org/project/easy-tdx/)
通达信 TCP 行情协议客户端。支持 A 股、港股、美股、期货全市场;内置 `easy-tdx` CLI 工具,默认 JSON 输出,天然适配 Claude Code、OpenClaw、Hermes 等 AI Agent 工具链。提供同步 + asyncio 双接口;strict mypy 通过;每一层编解码都有离线 fixture 测试覆盖。
通达信 TCP 行情协议客户端。支持 A 股、港股、美股、期货全市场;内置 `easy-tdx` CLI 工具,默认 JSON 输出,天然适配 Claude Code、OpenClaw、Hermes 等 AI Agent 工具链。提供同步 + asyncio 双接口;支持离线数据读取与写入同步;strict mypy 通过;每一层编解码都有离线 fixture 测试覆盖。
## 安装
@@ -213,7 +213,7 @@ easy-tdx ex quote-list HK_MAIN_BOARD --table # 港股商品列表
easy-tdx ex tick HK_MAIN_BOARD 00700 --table # 港股分时
```
### 离线数据(无需网络
### 离线数据(读取 + 写入同步
从本地通达信安装目录直接读取数据文件,无需网络连接:
@@ -228,6 +228,19 @@ easy-tdx offline financial C:\new_jyplug\vipdoc\fin\gpcw20260331.dat # 历史
easy-tdx offline blocks C:\new_jyplug\T0002\blocknew --table # 自定义板块
```
从服务端获取最新日线并写入本地 .day 文件,替代通达信内置下载功能:
```bash
# 同步单只股票日线(自动增量/全量)
easy-tdx offline sync-daily SZ 000001
easy-tdx offline sync-daily SH 600519 --vipdoc C:\new_jyplug\vipdoc
# 一键同步沪深全市场(每天一条命令)
easy-tdx offline sync-all
```
> 建议在通达信关闭时执行 sync 命令,避免文件被锁定。空文件自动全量下载,已有数据只做增量追加。
## CLI 命令汇总
| 命令 | 说明 |
@@ -261,6 +274,8 @@ easy-tdx offline blocks C:\new_jyplug\T0002\blocknew --table # 自定
| `ex markets` | 列出可用扩展市场 |
| `offline home` | 检测通达信安装目录 |
| `offline daily` | A 股日线(本地 .day 文件) |
| `offline sync-daily` | 从服务端同步单只股票日线到本地 .day 文件 |
| `offline sync-all` | 一键同步沪深全市场日线(扫描本地 .day 文件) |
| `offline min` | 分钟线(本地 .5/.lc1/.lc5 文件) |
| `offline ex-files` | 列出扩展市场可用文件 |
| `offline ex-daily` | 扩展市场日线(期货/港股/外盘) |
@@ -543,6 +558,29 @@ bars = read_daily_bars(filepath)
支持:日线、分钟线、扩展市场日线、板块、股本变迁、历史财务数据。
### 离线数据写入同步
从服务端获取最新数据并追加写入本地通达信数据文件:
```python
from easy_tdx.offline import (
encode_daily_bar, append_daily_bars, get_last_bar_date,
encode_5min_bar, append_5min_bars,
encode_lc_min_bar, append_lc_min_bars,
)
from easy_tdx import Market
from easy_tdx.client import TdxClient
# 追加日线到 .day 文件(自动跳过重复日期)
from easy_tdx.offline import sync_daily_bars_from_security_bars
# 手动编码单条记录
bar_bytes = encode_daily_bar(bar, price_coeff=0.01, vol_coeff=0.01)
# 获取文件末尾日期
last_date = get_last_bar_date("C:/new_jyplug/vipdoc/sh/lday/sh600000.day")
```
v1.5.0 起可通过 CLI 直接使用:
```bash
@@ -720,7 +758,7 @@ src/easy_tdx/
├── commands/ # 标准协议命令(无 IO)
├── codec/ # price / volume / datetime / frame / bitmap 编解码
├── models/ # 纯 dataclass,无业务逻辑
├── offline/ # 离线数据读模块
├── offline/ # 离线数据读模块(读取 + 写入同步)
└── cli/ # easy-tdx CLIclick
```
@@ -747,6 +785,18 @@ ruff format --check src/ tests/ # format check
## Changelog
### 1.6.0 (2026-06-07)
**离线数据写入同步** — 从服务端获取最新日线数据并写入本地通达信 .day 文件,替代通达信内置下载功能。
- 新增 `offline sync-daily` CLI 命令:同步单只股票日线,自动增量/全量判断,支持分页获取完整历史
- 新增 `offline sync-all` CLI 命令:一键扫描沪深全市场 .day 文件并同步
- 新增 `write_daily.py` 模块:日线编解码(`encode_daily_bar`)、追加写入(`append_daily_bars`)、末尾日期检测
- 新增 `write_ex_daily.py` 模块:扩展市场日线写入(期货/港股,价格 float32)
- 新增 `write_min_bar.py` 模块:分钟线写入(.5/.lc1/.lc5 格式)
- 写入自动跳过重复日期,空文件自动全量下载,已有数据只做增量追加
- 50 个新增单元测试覆盖编解码 round-trip、追加去重、边界条件
### 1.5.0 (2026-06-02)
**离线数据 CLI 命令** — 新增 `offline` 命令组,无需网络即可通过 CLI 读取本地通达信数据文件。
+2 -2
View File
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
[project]
name = "easy-tdx"
version = "1.5.0"
description = "通达信 TCP 协议行情数据客户端,支持在线行情离线本地数据读取"
version = "1.6.0"
description = "通达信 TCP 协议行情数据客户端,支持在线行情离线数据读取与写入同步"
readme = "README.md"
requires-python = ">=3.10"
dependencies = ["pandas>=2.0", "tzdata>=2024.1", "click>=8.0"]
+252 -3
View File
@@ -1,15 +1,25 @@
"""离线本地数据读命令(无需网络,读取本地通达信数据文件"""
"""离线本地数据读命令 —— 读取本地通达信数据文件 & 从服务端同步写入"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
import pandas as pd
from ..client import TdxClient
from ..models.bar import SecurityBar
@click.group()
def offline() -> None:
"""离线本地数据读取(无需网络,读取本地通达信数据文件)。
"""离线本地数据读写(读取本地通达信数据文件 & 从服务端同步写入)。
需要本地已安装通达信并下载过对应数据。
读取需要本地已安装通达信并下载过对应数据。
sync-daily 可从服务端获取最新日线并追加到本地 .day 文件。
示例:
@@ -22,6 +32,10 @@ def offline() -> None:
easy-tdx offline ex-files --table
easy-tdx offline ex-daily 29#A1801 --table
easy-tdx offline sync-daily SZ 000001
easy-tdx offline sync-daily SH 600519 --vipdoc C:\\new_jyplug\\vipdoc
"""
pass
@@ -412,3 +426,238 @@ def blocks(
for b in result
]
print_output(pd.DataFrame(rows), fmt)
# ---------------------------------------------------------------------------
# sync-daily:从服务端同步日线到本地 .day 文件
# ---------------------------------------------------------------------------
def _df_to_bars(df: pd.DataFrame) -> list[SecurityBar]:
"""将日线 DataFrame 转换为 SecurityBar 列表(按日期升序)。"""
from ..models.bar import SecurityBar
bars: list[SecurityBar] = []
for _, row in df.iterrows():
dt = row["date"]
bars.append(
SecurityBar(
open=row["open"],
close=row["close"],
high=row["high"],
low=row["low"],
vol=row["vol"],
amount=row["amount"],
year=dt.year,
month=dt.month,
day=dt.day,
hour=0,
minute=0,
)
)
bars.sort(key=lambda b: b.year * 10000 + b.month * 100 + b.day)
return bars
def _fetch_all_daily_bars(
client: TdxClient, market: int, code: str, need_full: bool = False
) -> list[SecurityBar]:
"""从服务端分页获取全部日线数据。
Args:
client: 已连接的 TdxClient。
market: 市场代码(0=SZ, 1=SH)。
code: 6 位股票代码。
need_full: True 表示拉取全量历史(空文件场景),
False 表示只拉最近一页(增量更新)。
Returns:
SecurityBar 列表(按日期升序)。
"""
from ..models.enums import KlineCategory
all_bars: list[SecurityBar] = []
start = 0
page_size = 800
max_pages = 50 if need_full else 1 # 50 页 = 40000 条,足够覆盖 A 股全部历史
for _ in range(max_pages):
df = client.get_security_bars(market, code, KlineCategory.DAY, start, page_size)
if df.empty:
break
all_bars.extend(_df_to_bars(df))
if len(df) < page_size:
break # 最后一页不满,已无更多数据
start += page_size
# 去重并按日期升序排列(跨页可能有重叠)
seen: set[tuple[int, int, int]] = set()
unique: list[SecurityBar] = []
for b in all_bars:
key = (b.year, b.month, b.day)
if key not in seen:
seen.add(key)
unique.append(b)
unique.sort(key=lambda b: b.year * 10000 + b.month * 100 + b.day)
return unique
def _sync_one_daily(client: TdxClient, filepath: Path) -> tuple[int, str]:
"""同步单只股票日线,返回 (写入条数, 状态消息)。"""
from ..offline import append_daily_bars, get_last_bar_date
from ..offline.daily_bar import _SECURITY_COEFFICIENTS, _detect_security_type
# 文件名 → 市场 + 代码
name = filepath.name.lower() # e.g. sh600000.day
exchange = name[:2] # "sh" or "sz"
code = name[2:8]
market = 1 if exchange == "sh" else 0 # Market.SH=1, Market.SZ=0
# 判断是否需要全量拉取(空文件 → 全量,有数据 → 增量)
last_date = get_last_bar_date(filepath)
need_full = last_date is None
# 从服务端分页获取日线
bars = _fetch_all_daily_bars(client, market, code, need_full=need_full)
if not bars:
return 0, "服务端无数据"
# 检测证券类型获取系数
sec_type = _detect_security_type(filepath.name)
price_coeff, vol_coeff = _SECURITY_COEFFICIENTS.get(sec_type, (0.01, 0.01))
# 追加写入
written = append_daily_bars(filepath, bars, price_coeff, vol_coeff)
if written > 0:
return written, f"+{written}"
return 0, "已是最新"
@offline.command("sync-daily")
@click.argument("market")
@click.argument("code")
@click.option("--vipdoc", default=None, help="vipdoc 目录路径(默认自动检测)")
def sync_daily(market: str, code: str, vipdoc: str | None) -> None:
"""从服务端同步日线数据到本地 .day 文件。
自动检测本地文件末尾日期,从服务端分页获取缺失的数据并追加写入。
空文件自动全量下载,已有数据只做增量更新。
建议在通达信关闭时执行,避免文件被锁定。
MARKET: 市场代码(SZ/SH
CODE: 6 位股票代码
示例:
easy-tdx offline sync-daily SZ 000001
easy-tdx offline sync-daily SH 000001 --vipdoc C:\\new_jyplug\\vipdoc
"""
from ..client import TdxClient
from ..offline import find_daily_bar_file
from .output import print_error
from .parsers import parse_market
mkt = parse_market(market)
try:
filepath = find_daily_bar_file(mkt, code, vipdoc)
click.echo(f"目标文件: {filepath}")
click.echo("正在连接服务端获取日线数据...")
with TdxClient.from_best_host() as client:
written, msg = _sync_one_daily(client, filepath)
if written > 0:
click.echo(f"✓ 成功写入 {written} 条新记录")
else:
click.echo(f"本地已是最新,无需写入 ({msg})")
except PermissionError:
click.echo(f"✗ 文件被锁定,请关闭通达信后重试: {filepath}", err=True)
raise SystemExit(1)
except Exception as e:
print_error(str(e))
raise SystemExit(1)
# ---------------------------------------------------------------------------
# sync-all:一键同步全部日线
# ---------------------------------------------------------------------------
@offline.command("sync-all")
@click.option("--vipdoc", default=None, help="vipdoc 目录路径(默认自动检测)")
def sync_all(vipdoc: str | None) -> None:
"""一键同步全部本地日线数据(沪深全市场)。
扫描 vipdoc 下所有 .day 文件,自动连接服务端获取最新数据并追加写入。
建议在通达信关闭时执行,避免文件被锁定。
示例:
easy-tdx offline sync-all
easy-tdx offline sync-all --vipdoc C:\\new_jyplug\\vipdoc
"""
import time
from ..client import TdxClient
from ..offline.paths import resolve_vipdoc
try:
vipdoc_path = resolve_vipdoc(vipdoc)
except Exception as e:
click.echo(f"{e}", err=True)
raise SystemExit(1)
# 1. 扫描所有 .day 文件
all_files: list[Path] = []
for exchange in ("sh", "sz"):
lday_dir = vipdoc_path / exchange / "lday"
if lday_dir.is_dir():
all_files.extend(sorted(lday_dir.glob("*.day")))
if not all_files:
click.echo("未找到任何 .day 文件,请确认 vipdoc 路径正确")
raise SystemExit(0)
total = len(all_files)
click.echo(f"发现 {total} 个 .day 文件,开始同步...")
# 2. 连接服务端,逐个同步
success = 0
skipped = 0
failed = 0
total_written = 0
with TdxClient.from_best_host() as client:
for idx, filepath in enumerate(all_files, 1):
name = filepath.name
try:
written, msg = _sync_one_daily(client, filepath)
total_written += written
if written > 0:
success += 1
else:
skipped += 1
click.echo(f" [{idx}/{total}] {name}: {msg}")
except PermissionError:
failed += 1
click.echo(f" [{idx}/{total}] {name}: ✗ 文件被锁定", err=True)
except Exception as e:
failed += 1
click.echo(f" [{idx}/{total}] {name}: ✗ {e}", err=True)
# 每 100 只暂停一小段,避免请求过快被服务器断开
if idx % 100 == 0:
time.sleep(0.2)
# 3. 汇总
click.echo("")
summary = (
f"同步完成: {total} 只 | "
f"更新 {success} | 已是最新 {skipped} | "
f"失败 {failed} | 共写入 {total_written}"
)
click.echo(summary)
+41 -4
View File
@@ -1,4 +1,4 @@
"""离线数据读模块 —— 从本地通达信安装目录读取数据文件。"""
"""离线数据读模块 —— 从本地通达信安装目录读取/写入数据文件。"""
from .block import CustomerBlock, read_block_dat, read_customer_blocks
from .daily_bar import find_daily_bar_file, read_daily_bars
@@ -8,21 +8,58 @@ from .gbbq import GbbqRecord, read_gbbq
from .history_financial import read_history_financial
from .min_bar import read_5min_bars, read_lc_min_bars
from .paths import detect_tdx_home, resolve_vipdoc
from .write_daily import (
append_daily_bars,
encode_daily_bar,
get_last_bar_date,
sync_daily_bars_from_security_bars,
)
from .write_ex_daily import (
append_ex_daily_bars,
encode_ex_daily_bar,
get_last_ex_bar_date,
sync_ex_daily_bars,
)
from .write_min_bar import (
append_5min_bars,
append_lc_min_bars,
encode_5min_bar,
encode_lc_min_bar,
get_last_5min_bar_datetime,
get_last_lc_min_bar_datetime,
)
__all__ = [
# 路径
"detect_tdx_home",
"resolve_vipdoc",
# 日线
# 日线读取
"read_daily_bars",
"find_daily_bar_file",
# 分钟线
# 日线写入
"encode_daily_bar",
"append_daily_bars",
"get_last_bar_date",
"sync_daily_bars_from_security_bars",
# 扩展市场日线写入
"encode_ex_daily_bar",
"append_ex_daily_bars",
"get_last_ex_bar_date",
"sync_ex_daily_bars",
# 分钟线写入
"encode_5min_bar",
"encode_lc_min_bar",
"append_5min_bars",
"append_lc_min_bars",
"get_last_5min_bar_datetime",
"get_last_lc_min_bar_datetime",
# 分钟线读取
"read_5min_bars",
"read_lc_min_bars",
"find_5min_bar_file",
"find_lc1_bar_file",
"find_lc5_bar_file",
# 扩展市场
# 扩展市场读取
"ExDailyBar",
"read_ex_daily_bars",
# 板块
+145
View File
@@ -0,0 +1,145 @@
"""离线日线数据写入 —— 将 SecurityBar 编码并追加到 .day 文件。"""
from __future__ import annotations
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",
]
# ---------------------------------------------------------------------------
# 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 文件最后一条记录的日期。
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
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 date_int
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)
# 获取文件末尾日期,用于去重
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)
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)
+93
View File
@@ -0,0 +1,93 @@
"""离线扩展市场日线写入 —— 将 ExDailyBar 编码并追加到 .day 文件。"""
from __future__ import annotations
from pathlib import Path
from .ex_daily_bar import _EX_DAILY_FMT, ExDailyBar
__all__ = [
"encode_ex_daily_bar",
"append_ex_daily_bars",
"get_last_ex_bar_date",
"sync_ex_daily_bars",
]
def encode_ex_daily_bar(bar: ExDailyBar) -> bytes:
"""将 ExDailyBar 编码为 32 字节扩展市场 .day 记录。
扩展市场价格直接为 float32,无需系数转换。
"""
date_int = bar.year * 10000 + bar.month * 100 + bar.day
return _EX_DAILY_FMT.pack(
date_int,
bar.open,
bar.high,
bar.low,
bar.close,
bar.amount,
bar.vol,
bar.settlement,
)
def get_last_ex_bar_date(filepath: str | Path) -> int | None:
"""读取扩展市场 .day 文件最后一条记录的日期。
Returns:
YYYYMMDD 整数,文件为空或太短时返回 None。
"""
filepath = Path(filepath)
if not filepath.is_file():
return None
size = filepath.stat().st_size
if size < _EX_DAILY_FMT.size:
return None
with filepath.open("rb") as f:
f.seek(size - _EX_DAILY_FMT.size)
last_record = f.read(_EX_DAILY_FMT.size)
(date_int, *_) = _EX_DAILY_FMT.unpack(last_record)
return date_int
def _bar_date_int(bar: ExDailyBar) -> int:
return bar.year * 10000 + bar.month * 100 + bar.day
def append_ex_daily_bars(
filepath: str | Path,
bars: list[ExDailyBar],
) -> int:
"""将扩展市场 bars 追加写入 .day 文件,自动跳过重复日期。
Returns:
实际写入的记录数。
"""
filepath = Path(filepath)
last_date = get_last_ex_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_ex_daily_bar(b) for b in new_bars)
with filepath.open("ab") as f:
f.write(encoded)
return len(new_bars)
def sync_ex_daily_bars(
filepath: str | Path,
server_bars: list[ExDailyBar],
) -> int:
"""将服务端获取的扩展市场日线同步写入本地 .day 文件。
Returns:
实际写入的记录数。
"""
return append_ex_daily_bars(filepath, server_bars)
+186
View File
@@ -0,0 +1,186 @@
"""离线分钟线写入 —— 将 SecurityBar 编码并追加到 .5 / .lc1 / .lc5 文件。"""
from __future__ import annotations
from pathlib import Path
from ..models.bar import SecurityBar
from .min_bar import _LC_MIN_FMT, _MIN_FMT
__all__ = [
"encode_5min_bar",
"encode_lc_min_bar",
"append_5min_bars",
"append_lc_min_bars",
"get_last_5min_bar_datetime",
"get_last_lc_min_bar_datetime",
]
# ---------------------------------------------------------------------------
# date/time 编码(与 min_bar._decode_tdx_date/time 互逆)
# ---------------------------------------------------------------------------
def _encode_tdx_date(year: int, month: int, day: int) -> int:
"""将 (year, month, day) 编码为 2 字节压缩日期。"""
return (year - 2004) * 2048 + month * 100 + day
def _encode_tdx_time(hour: int, minute: int) -> int:
"""将 (hour, minute) 编码为从 0:00 起的分钟数。"""
return hour * 60 + minute
def _bar_datetime_key(bar: SecurityBar) -> tuple[int, int, int, int, int]:
return (bar.year, bar.month, bar.day, bar.hour, bar.minute)
# ---------------------------------------------------------------------------
# .5 文件 (OHLC 为整数 / 100)
# ---------------------------------------------------------------------------
def encode_5min_bar(bar: SecurityBar) -> bytes:
"""将 SecurityBar 编码为 32 字节 .5 记录。
OHLC 为整数(实际价格 × 100),amount 为 float32。
"""
return _MIN_FMT.pack(
_encode_tdx_date(bar.year, bar.month, bar.day),
_encode_tdx_time(bar.hour, bar.minute),
int(round(bar.open * 100)),
int(round(bar.high * 100)),
int(round(bar.low * 100)),
int(round(bar.close * 100)),
bar.amount, # float32
int(round(bar.vol)),
0, # reserved
)
def get_last_5min_bar_datetime(
filepath: str | Path,
) -> tuple[int, int, int, int, int] | None:
"""读取 .5 文件最后一条记录的日期时间。
Returns:
(year, month, day, hour, minute) 元组,文件为空时返回 None。
"""
filepath = Path(filepath)
if not filepath.is_file():
return None
size = filepath.stat().st_size
if size < _MIN_FMT.size:
return None
with filepath.open("rb") as f:
f.seek(size - _MIN_FMT.size)
last_record = f.read(_MIN_FMT.size)
from .min_bar import _decode_tdx_date, _decode_tdx_time
date_num, time_num, *_ = _MIN_FMT.unpack(last_record)
year, month, day = _decode_tdx_date(date_num)
hour, minute = _decode_tdx_time(time_num)
return (year, month, day, hour, minute)
def append_5min_bars(
filepath: str | Path,
bars: list[SecurityBar],
) -> int:
"""将 5 分钟线 bars 追加写入 .5 文件,自动跳过重复时间点。
Returns:
实际写入的记录数。
"""
filepath = Path(filepath)
last_dt = get_last_5min_bar_datetime(filepath)
if last_dt is not None:
new_bars = [b for b in bars if _bar_datetime_key(b) > last_dt]
else:
new_bars = list(bars)
if not new_bars:
return 0
encoded = b"".join(encode_5min_bar(b) for b in new_bars)
with filepath.open("ab") as f:
f.write(encoded)
return len(new_bars)
# ---------------------------------------------------------------------------
# .lc1 / .lc5 文件 (OHLC 为 float32)
# ---------------------------------------------------------------------------
def encode_lc_min_bar(bar: SecurityBar) -> bytes:
"""将 SecurityBar 编码为 32 字节 .lc1/.lc5 记录。
OHLC 为 float32,无需系数转换。
"""
return _LC_MIN_FMT.pack(
_encode_tdx_date(bar.year, bar.month, bar.day),
_encode_tdx_time(bar.hour, bar.minute),
bar.open,
bar.high,
bar.low,
bar.close,
bar.amount, # float32
int(round(bar.vol)),
0, # reserved
)
def get_last_lc_min_bar_datetime(
filepath: str | Path,
) -> tuple[int, int, int, int, int] | None:
"""读取 .lc1/.lc5 文件最后一条记录的日期时间。
Returns:
(year, month, day, hour, minute) 元组,文件为空时返回 None。
"""
filepath = Path(filepath)
if not filepath.is_file():
return None
size = filepath.stat().st_size
if size < _LC_MIN_FMT.size:
return None
with filepath.open("rb") as f:
f.seek(size - _LC_MIN_FMT.size)
last_record = f.read(_LC_MIN_FMT.size)
from .min_bar import _decode_tdx_date, _decode_tdx_time
date_num, time_num, *_ = _LC_MIN_FMT.unpack(last_record)
year, month, day = _decode_tdx_date(date_num)
hour, minute = _decode_tdx_time(time_num)
return (year, month, day, hour, minute)
def append_lc_min_bars(
filepath: str | Path,
bars: list[SecurityBar],
) -> int:
"""将分钟线 bars 追加写入 .lc1/.lc5 文件,自动跳过重复时间点。
Returns:
实际写入的记录数。
"""
filepath = Path(filepath)
last_dt = get_last_lc_min_bar_datetime(filepath)
if last_dt is not None:
new_bars = [b for b in bars if _bar_datetime_key(b) > last_dt]
else:
new_bars = list(bars)
if not new_bars:
return 0
encoded = b"".join(encode_lc_min_bar(b) for b in new_bars)
with filepath.open("ab") as f:
f.write(encoded)
return len(new_bars)
+345
View File
@@ -0,0 +1,345 @@
"""离线日线写入测试(纯离线,无网络)。"""
from __future__ import annotations
import struct
from pathlib import Path
from easy_tdx.models.bar import SecurityBar
from easy_tdx.offline.daily_bar import (
_DAILY_FMT,
read_daily_bars,
)
from easy_tdx.offline.write_daily import (
append_daily_bars,
encode_daily_bar,
get_last_bar_date,
sync_daily_bars_from_security_bars,
)
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _make_bar(
year: int = 2026,
month: int = 6,
day: int = 6,
open_: float = 10.25,
high: float = 10.50,
low: float = 10.10,
close: float = 10.30,
vol: float = 100000.0,
amount: float = 1025000.0,
) -> SecurityBar:
return SecurityBar(
open=open_,
close=close,
high=high,
low=low,
vol=vol,
amount=amount,
year=year,
month=month,
day=day,
hour=0,
minute=0,
)
def _make_raw_bar(
year: int = 2026,
month: int = 6,
day: int = 6,
open_int: int = 1025,
high_int: int = 1050,
low_int: int = 1010,
close_int: int = 1030,
amount: float = 1025000.0,
vol_int: int = 100000,
reserved: int = 0,
) -> bytes:
"""直接用原始整数构造一条 32 字节 .day 记录。"""
date_int = year * 10000 + month * 100 + day
return _DAILY_FMT.pack(
date_int, open_int, high_int, low_int, close_int, amount, vol_int, reserved
)
# ---------------------------------------------------------------------------
# encode_daily_bar
# ---------------------------------------------------------------------------
class TestEncodeDailyBar:
"""编码测试:SecurityBar → 32 字节。"""
def test_output_length(self) -> None:
bar = _make_bar()
result = encode_daily_bar(bar, price_coeff=0.01, vol_coeff=0.01)
assert len(result) == 32
def test_date_encoding(self) -> None:
bar = _make_bar(year=2025, month=3, day=15)
result = encode_daily_bar(bar, price_coeff=0.01, vol_coeff=0.01)
date_int = struct.unpack_from("<I", result, 0)[0]
assert date_int == 20250315
def test_price_encoding_a_stock(self) -> None:
"""A股:系数 0.01,即 float × 100 → 整数。"""
bar = _make_bar(open_=10.25)
result = encode_daily_bar(bar, price_coeff=0.01, vol_coeff=0.01)
open_int = struct.unpack_from("<I", result, 4)[0]
assert open_int == 1025
def test_price_encoding_index(self) -> None:
"""指数:系数 0.01。"""
bar = _make_bar(open_=3250.18)
result = encode_daily_bar(bar, price_coeff=0.01, vol_coeff=1.0)
open_int = struct.unpack_from("<I", result, 4)[0]
assert open_int == 325018
def test_price_encoding_fund(self) -> None:
"""基金:系数 0.001,即 float × 1000 → 整数。"""
bar = _make_bar(open_=1.523)
result = encode_daily_bar(bar, price_coeff=0.001, vol_coeff=1.0)
open_int = struct.unpack_from("<I", result, 4)[0]
assert open_int == 1523
def test_volume_encoding_a_stock(self) -> None:
"""A股量系数 0.01。"""
bar = _make_bar(vol=12345.67)
result = encode_daily_bar(bar, price_coeff=0.01, vol_coeff=0.01)
vol_int = struct.unpack_from("<I", result, 24)[0]
assert vol_int == 1234567
def test_volume_encoding_index(self) -> None:
"""指数量系数 1.0。"""
bar = _make_bar(vol=123456789.0)
result = encode_daily_bar(bar, price_coeff=0.01, vol_coeff=1.0)
vol_int = struct.unpack_from("<I", result, 24)[0]
assert vol_int == 123456789
def test_amount_as_float32(self) -> None:
bar = _make_bar(amount=1_025_000.0)
result = encode_daily_bar(bar, price_coeff=0.01, vol_coeff=0.01)
(amt_out,) = struct.unpack_from("<f", result, 20)
assert abs(amt_out - 1_025_000.0) < 1.0
def test_reserved_is_zero(self) -> None:
bar = _make_bar()
result = encode_daily_bar(bar, price_coeff=0.01, vol_coeff=0.01)
reserved = struct.unpack_from("<I", result, 28)[0]
assert reserved == 0
# ---------------------------------------------------------------------------
# round-trip: encode → read back
# ---------------------------------------------------------------------------
class TestRoundTrip:
"""编码后写文件,再读回,验证数据一致。"""
def test_single_bar_round_trip(self, tmp_path: Path) -> None:
bar = _make_bar(
open_=10.25, high=10.50, low=10.10, close=10.30, vol=100000.0, amount=1025000.0
)
encoded = encode_daily_bar(bar, price_coeff=0.01, vol_coeff=0.01)
filepath = tmp_path / "sh600000.day"
filepath.write_bytes(encoded)
bars = read_daily_bars(filepath)
assert len(bars) == 1
b = bars[0]
assert b.year == 2026 and b.month == 6 and b.day == 6
assert abs(b.open - 10.25) < 0.01
assert abs(b.high - 10.50) < 0.01
assert abs(b.low - 10.10) < 0.01
assert abs(b.close - 10.30) < 0.01
assert abs(b.vol - 100000.0) < 1.0
assert abs(b.amount - 1025000.0) < 1.0
def test_multiple_bars_round_trip(self, tmp_path: Path) -> None:
bars_in = [
_make_bar(year=2026, month=6, day=4, open_=10.0, close=10.1),
_make_bar(year=2026, month=6, day=5, open_=10.1, close=10.2),
_make_bar(year=2026, month=6, day=6, open_=10.2, close=10.3),
]
encoded = b"".join(encode_daily_bar(b, price_coeff=0.01, vol_coeff=0.01) for b in bars_in)
filepath = tmp_path / "sh600000.day"
filepath.write_bytes(encoded)
bars_out = read_daily_bars(filepath)
assert len(bars_out) == 3
assert bars_out[0].day == 4
assert bars_out[2].day == 6
def test_index_round_trip(self, tmp_path: Path) -> None:
"""指数:价格系数 0.01,量系数 1.0。"""
bar = _make_bar(open_=3250.18, close=3260.5, vol=123456789.0)
encoded = encode_daily_bar(bar, price_coeff=0.01, vol_coeff=1.0)
filepath = tmp_path / "sh000001.day"
filepath.write_bytes(encoded)
bars = read_daily_bars(filepath)
assert len(bars) == 1
assert abs(bars[0].open - 3250.18) < 0.01
assert abs(bars[0].vol - 123456789.0) < 1.0
# ---------------------------------------------------------------------------
# get_last_bar_date
# ---------------------------------------------------------------------------
class TestGetLastBarDate:
def test_returns_last_date(self, tmp_path: Path) -> None:
filepath = tmp_path / "sh600000.day"
filepath.write_bytes(_make_raw_bar(year=2026, month=6, day=5))
assert get_last_bar_date(filepath) == 20260605
def test_returns_none_for_empty_file(self, tmp_path: Path) -> None:
filepath = tmp_path / "sh600000.day"
filepath.write_bytes(b"")
assert get_last_bar_date(filepath) is None
def test_returns_none_for_short_file(self, tmp_path: Path) -> None:
filepath = tmp_path / "sh600000.day"
filepath.write_bytes(b"\x00" * 16) # < 32 bytes
assert get_last_bar_date(filepath) is None
def test_returns_last_of_multiple(self, tmp_path: Path) -> None:
data = (
_make_raw_bar(year=2026, month=6, day=3)
+ _make_raw_bar(year=2026, month=6, day=4)
+ _make_raw_bar(year=2026, month=6, day=5)
)
filepath = tmp_path / "sh600000.day"
filepath.write_bytes(data)
assert get_last_bar_date(filepath) == 20260605
# ---------------------------------------------------------------------------
# append_daily_bars
# ---------------------------------------------------------------------------
class TestAppendDailyBars:
def test_append_to_existing(self, tmp_path: Path) -> None:
filepath = tmp_path / "sh600000.day"
filepath.write_bytes(_make_raw_bar(year=2026, month=6, day=5))
new_bar = _make_bar(year=2026, month=6, day=6, open_=10.3, close=10.4)
append_daily_bars(filepath, [new_bar], price_coeff=0.01, vol_coeff=0.01)
bars = read_daily_bars(filepath)
assert len(bars) == 2
assert bars[0].day == 5
assert bars[1].day == 6
def test_append_to_empty_file(self, tmp_path: Path) -> None:
filepath = tmp_path / "sh600000.day"
filepath.write_bytes(b"")
new_bar = _make_bar(year=2026, month=6, day=6)
append_daily_bars(filepath, [new_bar], price_coeff=0.01, vol_coeff=0.01)
bars = read_daily_bars(filepath)
assert len(bars) == 1
assert bars[0].day == 6
def test_append_skips_duplicate_date(self, tmp_path: Path) -> None:
filepath = tmp_path / "sh600000.day"
filepath.write_bytes(_make_raw_bar(year=2026, month=6, day=6))
new_bar = _make_bar(year=2026, month=6, day=6) # same date
written = append_daily_bars(filepath, [new_bar], price_coeff=0.01, vol_coeff=0.01)
assert written == 0 # skipped
bars = read_daily_bars(filepath)
assert len(bars) == 1 # no duplicate
def test_append_multiple_filters_duplicates(self, tmp_path: Path) -> None:
filepath = tmp_path / "sh600000.day"
filepath.write_bytes(_make_raw_bar(year=2026, month=6, day=5))
bars_to_append = [
_make_bar(year=2026, month=6, day=5), # dup
_make_bar(year=2026, month=6, day=6), # new
_make_bar(year=2026, month=6, day=7), # new
]
written = append_daily_bars(filepath, bars_to_append, price_coeff=0.01, vol_coeff=0.01)
assert written == 2
bars = read_daily_bars(filepath)
assert len(bars) == 3
# ---------------------------------------------------------------------------
# sync_daily_bars_from_security_bars
# ---------------------------------------------------------------------------
class TestSyncDailyBars:
"""模拟完整同步流程(不需要真实服务端,手动构造 SecurityBar 列表)。"""
def test_sync_appends_new_only(self, tmp_path: Path) -> None:
filepath = tmp_path / "sh600000.day"
# 文件已有 6月4日和6月5日
filepath.write_bytes(
_make_raw_bar(year=2026, month=6, day=4) + _make_raw_bar(year=2026, month=6, day=5)
)
# 模拟服务端返回的数据:6月3日~6月7日
server_bars = [
_make_bar(year=2026, month=6, day=3),
_make_bar(year=2026, month=6, day=4),
_make_bar(year=2026, month=6, day=5),
_make_bar(year=2026, month=6, day=6),
_make_bar(year=2026, month=6, day=7),
]
written = sync_daily_bars_from_security_bars(
filepath, server_bars, price_coeff=0.01, vol_coeff=0.01
)
assert written == 2 # only 6/6 and 6/7
bars = read_daily_bars(filepath)
assert len(bars) == 4 # 6/4, 6/5 (original) + 6/6, 6/7 (new)
def test_sync_empty_file(self, tmp_path: Path) -> None:
filepath = tmp_path / "sh600000.day"
filepath.write_bytes(b"")
server_bars = [
_make_bar(year=2026, month=6, day=4),
_make_bar(year=2026, month=6, day=5),
]
written = sync_daily_bars_from_security_bars(
filepath, server_bars, price_coeff=0.01, vol_coeff=0.01
)
assert written == 2
bars = read_daily_bars(filepath)
assert len(bars) == 2
def test_sync_nothing_new(self, tmp_path: Path) -> None:
filepath = tmp_path / "sh600000.day"
filepath.write_bytes(_make_raw_bar(year=2026, month=6, day=7))
server_bars = [
_make_bar(year=2026, month=6, day=6),
_make_bar(year=2026, month=6, day=7),
]
written = sync_daily_bars_from_security_bars(
filepath, server_bars, price_coeff=0.01, vol_coeff=0.01
)
assert written == 0
+190
View File
@@ -0,0 +1,190 @@
"""离线扩展市场日线写入测试(纯离线,无网络)。"""
from __future__ import annotations
import struct
from pathlib import Path
from easy_tdx.offline.ex_daily_bar import _EX_DAILY_FMT, ExDailyBar, read_ex_daily_bars
from easy_tdx.offline.write_ex_daily import (
append_ex_daily_bars,
encode_ex_daily_bar,
get_last_ex_bar_date,
sync_ex_daily_bars,
)
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _make_ex_bar(
year: int = 2026,
month: int = 6,
day: int = 6,
open_: float = 3500.0,
high: float = 3550.0,
low: float = 3480.0,
close: float = 3520.0,
amount: int = 123456,
vol: int = 98765,
settlement: float = 3510.0,
) -> ExDailyBar:
return ExDailyBar(
open=open_,
close=close,
high=high,
low=low,
amount=amount,
vol=vol,
settlement=settlement,
hk_stock_amount=0.0,
year=year,
month=month,
day=day,
)
def _make_raw_ex_bar(
year: int = 2026,
month: int = 6,
day: int = 6,
open_: float = 3500.0,
high: float = 3550.0,
low: float = 3480.0,
close: float = 3520.0,
amount: int = 123456,
vol: int = 98765,
settlement: float = 3510.0,
) -> bytes:
date_int = year * 10000 + month * 100 + day
return _EX_DAILY_FMT.pack(date_int, open_, high, low, close, amount, vol, settlement)
# ---------------------------------------------------------------------------
# encode_ex_daily_bar
# ---------------------------------------------------------------------------
class TestEncodeExDailyBar:
def test_output_length(self) -> None:
bar = _make_ex_bar()
result = encode_ex_daily_bar(bar)
assert len(result) == 32
def test_date_encoding(self) -> None:
bar = _make_ex_bar(year=2025, month=12, day=25)
result = encode_ex_daily_bar(bar)
date_int = struct.unpack_from("<I", result, 0)[0]
assert date_int == 20251225
def test_float_prices_preserved(self) -> None:
bar = _make_ex_bar(open_=3500.5, high=3560.25, low=3479.75, close=3520.0)
result = encode_ex_daily_bar(bar)
op, hi, lo, cl = struct.unpack_from("<ffff", result, 4)
assert abs(op - 3500.5) < 0.01
assert abs(hi - 3560.25) < 0.01
assert abs(lo - 3479.75) < 0.01
assert abs(cl - 3520.0) < 0.01
def test_settlement_encoding(self) -> None:
bar = _make_ex_bar(settlement=3510.5)
result = encode_ex_daily_bar(bar)
(sett,) = struct.unpack_from("<f", result, 28)
assert abs(sett - 3510.5) < 0.01
# ---------------------------------------------------------------------------
# round-trip
# ---------------------------------------------------------------------------
class TestExRoundTrip:
def test_single_bar_round_trip(self, tmp_path: Path) -> None:
bar = _make_ex_bar(open_=3500.0, close=3520.0, vol=98765)
encoded = encode_ex_daily_bar(bar)
filepath = tmp_path / "29#A1801.day"
filepath.write_bytes(encoded)
bars = read_ex_daily_bars(filepath)
assert len(bars) == 1
b = bars[0]
assert b.year == 2026 and b.month == 6 and b.day == 6
assert abs(b.open - 3500.0) < 0.01
assert abs(b.close - 3520.0) < 0.01
assert b.vol == 98765
def test_multiple_bars_round_trip(self, tmp_path: Path) -> None:
bars_in = [
_make_ex_bar(year=2026, month=6, day=4, open_=3400.0),
_make_ex_bar(year=2026, month=6, day=5, open_=3450.0),
_make_ex_bar(year=2026, month=6, day=6, open_=3500.0),
]
encoded = b"".join(encode_ex_daily_bar(b) for b in bars_in)
filepath = tmp_path / "29#A1801.day"
filepath.write_bytes(encoded)
bars_out = read_ex_daily_bars(filepath)
assert len(bars_out) == 3
assert bars_out[0].day == 4
assert bars_out[2].day == 6
# ---------------------------------------------------------------------------
# get_last_ex_bar_date
# ---------------------------------------------------------------------------
class TestGetLastExBarDate:
def test_returns_last_date(self, tmp_path: Path) -> None:
filepath = tmp_path / "test.day"
filepath.write_bytes(_make_raw_ex_bar(year=2026, month=6, day=5))
assert get_last_ex_bar_date(filepath) == 20260605
def test_returns_none_for_empty(self, tmp_path: Path) -> None:
filepath = tmp_path / "test.day"
filepath.write_bytes(b"")
assert get_last_ex_bar_date(filepath) is None
# ---------------------------------------------------------------------------
# append & sync
# ---------------------------------------------------------------------------
class TestAppendExDailyBars:
def test_append_to_existing(self, tmp_path: Path) -> None:
filepath = tmp_path / "test.day"
filepath.write_bytes(_make_raw_ex_bar(year=2026, month=6, day=5))
new_bar = _make_ex_bar(year=2026, month=6, day=6)
written = append_ex_daily_bars(filepath, [new_bar])
assert written == 1
bars = read_ex_daily_bars(filepath)
assert len(bars) == 2
def test_skips_duplicate(self, tmp_path: Path) -> None:
filepath = tmp_path / "test.day"
filepath.write_bytes(_make_raw_ex_bar(year=2026, month=6, day=6))
new_bar = _make_ex_bar(year=2026, month=6, day=6)
written = append_ex_daily_bars(filepath, [new_bar])
assert written == 0
def test_sync_filters_correctly(self, tmp_path: Path) -> None:
filepath = tmp_path / "test.day"
filepath.write_bytes(_make_raw_ex_bar(year=2026, month=6, day=5))
server_bars = [
_make_ex_bar(year=2026, month=6, day=4),
_make_ex_bar(year=2026, month=6, day=5),
_make_ex_bar(year=2026, month=6, day=6),
]
written = sync_ex_daily_bars(filepath, server_bars)
assert written == 1
bars = read_ex_daily_bars(filepath)
assert len(bars) == 2
+276
View File
@@ -0,0 +1,276 @@
"""离线分钟线写入测试(纯离线,无网络)。"""
from __future__ import annotations
import struct
from pathlib import Path
from easy_tdx.models.bar import SecurityBar
from easy_tdx.offline.min_bar import (
_LC_MIN_FMT,
_MIN_FMT,
_decode_tdx_date,
_decode_tdx_time,
read_5min_bars,
read_lc_min_bars,
)
from easy_tdx.offline.write_min_bar import (
append_5min_bars,
append_lc_min_bars,
encode_5min_bar,
encode_lc_min_bar,
get_last_5min_bar_datetime,
get_last_lc_min_bar_datetime,
)
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _make_min_bar(
year: int = 2026,
month: int = 6,
day: int = 6,
hour: int = 9,
minute: int = 30,
open_: float = 10.25,
high: float = 10.50,
low: float = 10.10,
close: float = 10.30,
vol: float = 5000.0,
amount: float = 51250.0,
) -> SecurityBar:
return SecurityBar(
open=open_,
close=close,
high=high,
low=low,
vol=vol,
amount=amount,
year=year,
month=month,
day=day,
hour=hour,
minute=minute,
)
def _encode_tdx_date(year: int, month: int, day: int) -> int:
return (year - 2004) * 2048 + month * 100 + day
def _encode_tdx_time(hour: int, minute: int) -> int:
return hour * 60 + minute
def _make_raw_5min_bar(
year: int = 2026,
month: int = 6,
day: int = 6,
hour: int = 9,
minute: int = 30,
open_int: int = 1025,
high_int: int = 1050,
low_int: int = 1010,
close_int: int = 1030,
amount: float = 51250.0,
vol_int: int = 5000,
) -> bytes:
return _MIN_FMT.pack(
_encode_tdx_date(year, month, day),
_encode_tdx_time(hour, minute),
open_int,
high_int,
low_int,
close_int,
amount,
vol_int,
0,
)
def _make_raw_lc_min_bar(
year: int = 2026,
month: int = 6,
day: int = 6,
hour: int = 9,
minute: int = 30,
open_: float = 10.25,
high: float = 10.50,
low: float = 10.10,
close: float = 10.30,
amount: float = 51250.0,
vol: int = 5000,
) -> bytes:
return _LC_MIN_FMT.pack(
_encode_tdx_date(year, month, day),
_encode_tdx_time(hour, minute),
open_,
high,
low,
close,
amount,
vol,
0,
)
# ===========================================================================
# .5 文件 (5 分钟线)
# ===========================================================================
class TestEncode5minBar:
def test_output_length(self) -> None:
bar = _make_min_bar()
result = encode_5min_bar(bar)
assert len(result) == 32
def test_date_encoding(self) -> None:
bar = _make_min_bar(year=2026, month=6, day=5)
result = encode_5min_bar(bar)
date_num = struct.unpack_from("<H", result, 0)[0]
yr, mo, dy = _decode_tdx_date(date_num)
assert (yr, mo, dy) == (2026, 6, 5)
def test_time_encoding(self) -> None:
bar = _make_min_bar(hour=14, minute=30)
result = encode_5min_bar(bar)
time_num = struct.unpack_from("<H", result, 2)[0]
h, m = _decode_tdx_time(time_num)
assert (h, m) == (14, 30)
def test_price_encoding(self) -> None:
"""5 分钟线 OHLC 整数 × 100。"""
bar = _make_min_bar(open_=10.25)
result = encode_5min_bar(bar)
open_int = struct.unpack_from("<I", result, 4)[0]
assert open_int == 1025
class Test5minRoundTrip:
def test_single_bar_round_trip(self, tmp_path: Path) -> None:
bar = _make_min_bar(open_=10.25, close=10.30, vol=5000.0, amount=51250.0)
encoded = encode_5min_bar(bar)
filepath = tmp_path / "sh600000.5"
filepath.write_bytes(encoded)
bars = read_5min_bars(filepath)
assert len(bars) == 1
b = bars[0]
assert abs(b.open - 10.25) < 0.01
assert abs(b.close - 10.30) < 0.01
assert abs(b.vol - 5000.0) < 1.0
assert b.hour == 9 and b.minute == 30
def test_multiple_bars(self, tmp_path: Path) -> None:
bars_in = [
_make_min_bar(hour=9, minute=30),
_make_min_bar(hour=9, minute=35),
_make_min_bar(hour=9, minute=40),
]
encoded = b"".join(encode_5min_bar(b) for b in bars_in)
filepath = tmp_path / "sh600000.5"
filepath.write_bytes(encoded)
bars_out = read_5min_bars(filepath)
assert len(bars_out) == 3
assert bars_out[0].minute == 30
assert bars_out[2].minute == 40
class TestGetLast5minBarDatetime:
def test_returns_last_datetime(self, tmp_path: Path) -> None:
filepath = tmp_path / "sh600000.5"
filepath.write_bytes(_make_raw_5min_bar(year=2026, month=6, day=5, hour=15, minute=0))
result = get_last_5min_bar_datetime(filepath)
assert result == (2026, 6, 5, 15, 0)
def test_returns_none_for_empty(self, tmp_path: Path) -> None:
filepath = tmp_path / "sh600000.5"
filepath.write_bytes(b"")
assert get_last_5min_bar_datetime(filepath) is None
class TestAppend5minBars:
def test_append_to_existing(self, tmp_path: Path) -> None:
filepath = tmp_path / "sh600000.5"
filepath.write_bytes(_make_raw_5min_bar(hour=9, minute=30))
new_bar = _make_min_bar(hour=9, minute=35)
written = append_5min_bars(filepath, [new_bar])
assert written == 1
bars = read_5min_bars(filepath)
assert len(bars) == 2
def test_skips_duplicate(self, tmp_path: Path) -> None:
filepath = tmp_path / "sh600000.5"
filepath.write_bytes(_make_raw_5min_bar(hour=9, minute=30))
new_bar = _make_min_bar(hour=9, minute=30) # same datetime
written = append_5min_bars(filepath, [new_bar])
assert written == 0
# ===========================================================================
# .lc1 / .lc5 文件
# ===========================================================================
class TestEncodeLcMinBar:
def test_output_length(self) -> None:
bar = _make_min_bar()
result = encode_lc_min_bar(bar)
assert len(result) == 32
def test_float_prices_preserved(self) -> None:
bar = _make_min_bar(open_=10.255)
result = encode_lc_min_bar(bar)
(op,) = struct.unpack_from("<f", result, 4)
assert abs(op - 10.255) < 0.001
class TestLcMinRoundTrip:
def test_single_bar_round_trip(self, tmp_path: Path) -> None:
bar = _make_min_bar(open_=10.255, close=10.31, vol=5000.0, amount=51250.0)
encoded = encode_lc_min_bar(bar)
filepath = tmp_path / "sh600000.lc1"
filepath.write_bytes(encoded)
bars = read_lc_min_bars(filepath)
assert len(bars) == 1
b = bars[0]
assert abs(b.open - 10.255) < 0.001
assert abs(b.close - 10.31) < 0.001
assert b.hour == 9 and b.minute == 30
class TestGetLastLcMinBarDatetime:
def test_returns_last_datetime(self, tmp_path: Path) -> None:
filepath = tmp_path / "sh600000.lc1"
filepath.write_bytes(_make_raw_lc_min_bar(year=2026, month=6, day=5, hour=14, minute=55))
result = get_last_lc_min_bar_datetime(filepath)
assert result == (2026, 6, 5, 14, 55)
def test_returns_none_for_empty(self, tmp_path: Path) -> None:
filepath = tmp_path / "sh600000.lc1"
filepath.write_bytes(b"")
assert get_last_lc_min_bar_datetime(filepath) is None
class TestAppendLcMinBars:
def test_append_to_existing(self, tmp_path: Path) -> None:
filepath = tmp_path / "sh600000.lc1"
filepath.write_bytes(_make_raw_lc_min_bar(hour=9, minute=30))
new_bar = _make_min_bar(hour=9, minute=31)
written = append_lc_min_bars(filepath, [new_bar])
assert written == 1
bars = read_lc_min_bars(filepath)
assert len(bars) == 2