Files
easy_tdx_max/tests/unit/test_write_min_bar.py
Justin Gu d01b11fa74 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
2026-06-07 21:13:49 +08:00

277 lines
7.9 KiB
Python
Raw Permalink 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.
"""离线分钟线写入测试(纯离线,无网络)。"""
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