mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 15:44:15 +08:00
feat: 初始实现 xmtdx —— 从零实现通达信 TCP A 股行情客户端
替代年久失修的 pytdx,修复已知 bug,保留未解字段供逆向分析。 主要内容: - codec 层:get_price 变长编码、get_volume 自定义浮点、datetime/frame 解析 - transport 层:同步(socket)+ 异步(asyncio)双实现,共用命令层 - 命令层(11 条):security_count/list/quotes/bars、minute_time(今日+历史)、 transaction(当日+历史)、xdxr_info、finance_info、company_info - 高层 API:TdxClient + AsyncTdxClient - 单元测试 26 条,全部通过;真实服务器集成测试覆盖全部命令 修复 pytdx Bug #1–5:xdxr 循环读取错误位置、GBK 截断崩溃、 pre_close 误用 get_volume、逐笔/分时未解字段被丢弃 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
"""日期时间解码单元测试。"""
|
||||
|
||||
import struct
|
||||
|
||||
from xmtdx.codec.datetime_ import get_datetime, get_datetime_day, get_datetime_minute, get_time
|
||||
|
||||
|
||||
def _pack_minute(year: int, month: int, day: int, hour: int, minute: int) -> bytes:
|
||||
zipday = ((year - 2004) << 11) | (month * 100 + day)
|
||||
tminutes = hour * 60 + minute
|
||||
return struct.pack("<HH", zipday, tminutes)
|
||||
|
||||
|
||||
def _pack_day(year: int, month: int, day: int) -> bytes:
|
||||
return struct.pack("<I", year * 10000 + month * 100 + day)
|
||||
|
||||
|
||||
class TestGetDatetimeMinute:
|
||||
def test_basic(self):
|
||||
data = _pack_minute(2024, 4, 10, 14, 30)
|
||||
y, mo, d, h, mi, pos = get_datetime_minute(data, 0)
|
||||
assert (y, mo, d, h, mi) == (2024, 4, 10, 14, 30)
|
||||
assert pos == 4
|
||||
|
||||
def test_open_time(self):
|
||||
data = _pack_minute(2026, 1, 5, 9, 30)
|
||||
y, mo, d, h, mi, pos = get_datetime_minute(data, 0)
|
||||
assert h == 9 and mi == 30
|
||||
|
||||
def test_close_time(self):
|
||||
data = _pack_minute(2026, 1, 5, 15, 0)
|
||||
y, mo, d, h, mi, _ = get_datetime_minute(data, 0)
|
||||
assert h == 15 and mi == 0
|
||||
|
||||
|
||||
class TestGetDatetimeDay:
|
||||
def test_basic(self):
|
||||
data = _pack_day(2026, 4, 10)
|
||||
y, mo, d, pos = get_datetime_day(data, 0)
|
||||
assert (y, mo, d) == (2026, 4, 10)
|
||||
assert pos == 4
|
||||
|
||||
|
||||
class TestGetDatetime:
|
||||
def test_minute_category(self):
|
||||
data = _pack_minute(2026, 3, 15, 10, 0)
|
||||
for cat in (0, 1, 2, 3, 7, 8):
|
||||
y, mo, d, h, mi, _ = get_datetime(cat, data, 0)
|
||||
assert h == 10 and mi == 0
|
||||
|
||||
def test_day_category(self):
|
||||
data = _pack_day(2026, 3, 15)
|
||||
for cat in (4, 5, 6, 9):
|
||||
y, mo, d, h, mi, _ = get_datetime(cat, data, 0)
|
||||
assert (y, mo, d) == (2026, 3, 15)
|
||||
assert h == 15 and mi == 0
|
||||
|
||||
|
||||
class TestGetTime:
|
||||
def test_basic(self):
|
||||
data = struct.pack("<H", 14 * 60 + 30) # 14:30
|
||||
h, mi, pos = get_time(data, 0)
|
||||
assert h == 14 and mi == 30
|
||||
assert pos == 2
|
||||
@@ -0,0 +1,39 @@
|
||||
"""响应帧头解析与解压单元测试。"""
|
||||
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
from xmtdx.codec.frame import HEADER_SIZE, decompress_body, parse_header
|
||||
|
||||
|
||||
def _make_header(zipsize: int, unzipsize: int) -> bytes:
|
||||
return struct.pack("<IIIHH", 0, 0, 0, zipsize, unzipsize)
|
||||
|
||||
|
||||
class TestParseHeader:
|
||||
def test_uncompressed(self):
|
||||
h = parse_header(_make_header(100, 100))
|
||||
assert h.zipsize == 100
|
||||
assert h.unzipsize == 100
|
||||
|
||||
def test_compressed(self):
|
||||
h = parse_header(_make_header(50, 200))
|
||||
assert h.zipsize == 50
|
||||
assert h.unzipsize == 200
|
||||
|
||||
def test_header_size(self):
|
||||
assert HEADER_SIZE == 16
|
||||
|
||||
|
||||
class TestDecompressBody:
|
||||
def test_no_compression(self):
|
||||
h = parse_header(_make_header(5, 5))
|
||||
body = b"hello"
|
||||
assert decompress_body(h, body) == b"hello"
|
||||
|
||||
def test_zlib_decompression(self):
|
||||
original = b"hello world" * 10
|
||||
compressed = zlib.compress(original)
|
||||
h = parse_header(_make_header(len(compressed), len(original)))
|
||||
result = decompress_body(h, compressed)
|
||||
assert result == original
|
||||
@@ -0,0 +1,63 @@
|
||||
"""get_price / put_price 单元测试,测试向量来自 pytdx 实际报文。"""
|
||||
|
||||
import pytest
|
||||
from xmtdx.codec.price import get_price, put_price
|
||||
|
||||
|
||||
class TestGetPrice:
|
||||
def test_single_byte_zero(self):
|
||||
val, pos = get_price(b"\x00", 0)
|
||||
assert val == 0
|
||||
assert pos == 1
|
||||
|
||||
def test_single_byte_positive(self):
|
||||
# 0x27 = 0b00100111 → bit7=0(stop), bit6=0(pos), low6=0x27=39
|
||||
val, pos = get_price(bytes([0x27]), 0)
|
||||
assert val == 39
|
||||
assert pos == 1
|
||||
|
||||
def test_single_byte_negative(self):
|
||||
# bit6=1 → negative;low6=0x01 → -1
|
||||
val, pos = get_price(bytes([0x41]), 0)
|
||||
assert val == -1
|
||||
assert pos == 1
|
||||
|
||||
def test_multi_byte_positive(self):
|
||||
# 0x8F 0x01: bit7=1(continue), low6=0x0F=15; 0x01: bit7=0(stop), 7bits=1
|
||||
# value = 15 | (1 << 6) = 15 + 64 = 79
|
||||
val, pos = get_price(bytes([0x8F, 0x01]), 0)
|
||||
assert val == 79
|
||||
assert pos == 2
|
||||
|
||||
def test_pos_advances(self):
|
||||
data = bytes([0x05, 0x0A])
|
||||
val0, pos0 = get_price(data, 0)
|
||||
val1, pos1 = get_price(data, pos0)
|
||||
assert val0 == 5
|
||||
assert val1 == 10
|
||||
|
||||
def test_roundtrip(self):
|
||||
for v in [0, 1, -1, 63, 64, -64, 1000, -1000, 99999, -99999]:
|
||||
encoded = put_price(v)
|
||||
decoded, _ = get_price(encoded, 0)
|
||||
assert decoded == v, f"roundtrip failed for {v}"
|
||||
|
||||
|
||||
class TestPutPrice:
|
||||
def test_zero(self):
|
||||
assert put_price(0) == b"\x00"
|
||||
|
||||
def test_small_positive(self):
|
||||
b = put_price(5)
|
||||
val, _ = get_price(b, 0)
|
||||
assert val == 5
|
||||
|
||||
def test_small_negative(self):
|
||||
b = put_price(-5)
|
||||
val, _ = get_price(b, 0)
|
||||
assert val == -5
|
||||
|
||||
def test_large_value(self):
|
||||
b = put_price(100000)
|
||||
val, _ = get_price(b, 0)
|
||||
assert val == 100000
|
||||
@@ -0,0 +1,36 @@
|
||||
"""get_volume 单元测试,测试向量来自 pytdx 注释中的已知值。"""
|
||||
|
||||
import struct
|
||||
|
||||
from xmtdx.codec.volume import get_volume
|
||||
|
||||
|
||||
def _pack(ivol: int) -> bytes:
|
||||
return struct.pack("<I", ivol)
|
||||
|
||||
|
||||
class TestGetVolume:
|
||||
def test_zero(self):
|
||||
val, pos = get_volume(_pack(0), 0)
|
||||
assert val == 0.0
|
||||
assert pos == 4
|
||||
|
||||
def test_known_value_4098(self):
|
||||
# pytdx 注释 "4098 ---> 3.0" 含义:raw 4098 对应真实股数 3.0亿,
|
||||
# 但 get_volume(4098) ≈ 5.88e-39(接近零),说明 xdxr_info 里对股本字段
|
||||
# 调用 get_volume 是错误用法。xmtdx 在 xdxr_info 命令中会用正确的解码方式。
|
||||
val, pos = get_volume(_pack(4098), 0)
|
||||
assert abs(val) < 1e-30 # 接近零,与 pytdx 行为一致
|
||||
|
||||
def test_advances_pos(self):
|
||||
data = _pack(0) + _pack(0)
|
||||
_, pos = get_volume(data, 0)
|
||||
assert pos == 4
|
||||
_, pos2 = get_volume(data, pos)
|
||||
assert pos2 == 8
|
||||
|
||||
def test_nonnegative(self):
|
||||
# 成交量不应为负
|
||||
for raw in [0, 1000, 0x10000, 0x1000000, 0x7FFFFFFF]:
|
||||
val, _ = get_volume(_pack(raw), 0)
|
||||
assert val >= 0.0
|
||||
Reference in New Issue
Block a user