mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 22:44:17 +08:00
- 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
277 lines
7.9 KiB
Python
277 lines
7.9 KiB
Python
"""离线分钟线写入测试(纯离线,无网络)。"""
|
||
|
||
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
|