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:
minionszyw
2026-04-11 19:55:19 +08:00
co-authored by Claude Sonnet 4.6
commit 283682f6b4
39 changed files with 2298 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
"""xmtdx — 通达信 TCP 协议 A 股行情数据客户端。
快速开始::
from xmtdx import TdxClient, Market, KlineCategory
with TdxClient("180.153.18.170") as c:
count = c.get_security_count(Market.SH)
bars = c.get_security_bars(Market.SH, "600000", KlineCategory.DAY, 0, 5)
asyncio 版本::
import asyncio
from xmtdx import AsyncTdxClient, Market, KlineCategory
async def main():
async with AsyncTdxClient("180.153.18.170") as c:
bars = await c.get_security_bars(Market.SH, "600000", KlineCategory.DAY, 0, 5)
asyncio.run(main())
"""
from .client import AsyncTdxClient, TdxClient
from .exceptions import TdxCommandError, TdxConnectionError, TdxDecodeError, TdxError
from .models import (
XDXR_CATEGORY_NAMES,
CompanyInfoCategory,
FinanceInfo,
KlineCategory,
Market,
MinuteBar,
SecurityBar,
SecurityInfo,
SecurityQuote,
TransactionRecord,
XdxrRecord,
)
__all__ = [
# 客户端
"TdxClient",
"AsyncTdxClient",
# 枚举
"Market",
"KlineCategory",
# 数据模型
"SecurityBar",
"SecurityQuote",
"SecurityInfo",
"MinuteBar",
"TransactionRecord",
"XdxrRecord",
"XDXR_CATEGORY_NAMES",
"FinanceInfo",
"CompanyInfoCategory",
# 异常
"TdxError",
"TdxConnectionError",
"TdxDecodeError",
"TdxCommandError",
]
__version__ = "0.1.0"
+277
View File
@@ -0,0 +1,277 @@
"""高层行情 APITdxClient(同步)和 AsyncTdxClientasyncio)。"""
from types import TracebackType
from .commands.company_info import GetCompanyInfoCategoryCmd, GetCompanyInfoContentCmd
from .commands.finance_info import GetFinanceInfoCmd
from .commands.minute_time import GetHistoryMinuteTimeDataCmd, GetMinuteTimeDataCmd
from .commands.security_bars import GetIndexBarsCmd, GetSecurityBarsCmd
from .commands.security_count import GetSecurityCountCmd
from .commands.security_list import GetSecurityListCmd
from .commands.security_quotes import GetSecurityQuotesCmd
from .commands.transaction import GetHistoryTransactionDataCmd, GetTransactionDataCmd
from .commands.xdxr_info import GetXdxrInfoCmd
from .models.bar import SecurityBar
from .models.enums import KlineCategory, Market
from .models.finance import CompanyInfoCategory, FinanceInfo, XdxrRecord
from .models.quote import SecurityQuote
from .models.security import SecurityInfo
from .models.timeseries import MinuteBar, TransactionRecord
from .transport.async_ import AsyncTdxConnection
from .transport.sync import TdxConnection
_DEFAULT_HOST = "180.153.18.170"
_DEFAULT_PORT = 7709
# ============================================================
# 同步客户端
# ============================================================
class TdxClient:
"""同步通达信行情客户端。
使用示例::
with TdxClient("180.153.18.170") as c:
bars = c.get_security_bars(Market.SH, "600000", KlineCategory.DAY, 0, 100)
"""
def __init__(
self,
host: str = _DEFAULT_HOST,
port: int = _DEFAULT_PORT,
timeout: float = 15.0,
) -> None:
self._conn = TdxConnection(host, port, timeout)
def connect(self) -> None:
self._conn.connect()
def close(self) -> None:
self._conn.close()
def __enter__(self) -> "TdxClient":
self.connect()
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self.close()
# ------------------------------------------------------------------ #
# 市场信息
# ------------------------------------------------------------------ #
def get_security_count(self, market: Market) -> int:
"""获取市场证券总数。"""
return self._conn.execute(GetSecurityCountCmd(market))
def get_security_list(self, market: Market, start: int) -> list[SecurityInfo]:
"""获取证券列表(每页约1000条,按 start 分页)。"""
return self._conn.execute(GetSecurityListCmd(market, start))
def get_security_quotes(
self, stocks: list[tuple[Market, str]]
) -> list[SecurityQuote]:
"""批量获取实时五档行情(最多80只/次)。"""
return self._conn.execute(GetSecurityQuotesCmd(stocks))
# ------------------------------------------------------------------ #
# K 线
# ------------------------------------------------------------------ #
def get_security_bars(
self,
market: Market,
code: str,
category: KlineCategory,
start: int,
count: int = 800,
) -> list[SecurityBar]:
"""获取 K 线数据(最多800条/次,按 start 分页)。"""
return self._conn.execute(GetSecurityBarsCmd(market, code, category, start, count))
def get_index_bars(
self,
market: Market,
code: str,
category: KlineCategory,
start: int,
count: int = 800,
) -> list[SecurityBar]:
"""获取指数 K 线数据。"""
return self._conn.execute(GetIndexBarsCmd(market, code, category, start, count))
# ------------------------------------------------------------------ #
# 分时
# ------------------------------------------------------------------ #
def get_minute_time_data(self, market: Market, code: str) -> list[MinuteBar]:
"""获取今日分时数据(240条)。"""
return self._conn.execute(GetMinuteTimeDataCmd(market, code))
def get_history_minute_time_data(
self, market: Market, code: str, date: int
) -> list[MinuteBar]:
"""获取历史某日分时数据(date: YYYYMMDD)。"""
return self._conn.execute(GetHistoryMinuteTimeDataCmd(market, code, date))
# ------------------------------------------------------------------ #
# 逐笔成交
# ------------------------------------------------------------------ #
def get_transaction_data(
self, market: Market, code: str, start: int, count: int = 800
) -> list[TransactionRecord]:
"""获取当日逐笔成交(分页)。"""
return self._conn.execute(GetTransactionDataCmd(market, code, start, count))
def get_history_transaction_data(
self, market: Market, code: str, date: int, start: int, count: int = 800
) -> list[TransactionRecord]:
"""获取历史逐笔成交(date: YYYYMMDD,分页)。"""
return self._conn.execute(
GetHistoryTransactionDataCmd(market, code, date, start, count)
)
# ------------------------------------------------------------------ #
# 财务 / 公司
# ------------------------------------------------------------------ #
def get_xdxr_info(self, market: Market, code: str) -> list[XdxrRecord]:
"""获取除权除息历史记录。"""
return self._conn.execute(GetXdxrInfoCmd(market, code))
def get_finance_info(self, market: Market, code: str) -> FinanceInfo:
"""获取最新财务数据。"""
return self._conn.execute(GetFinanceInfoCmd(market, code))
def get_company_info_category(
self, market: Market, code: str
) -> list[CompanyInfoCategory]:
"""获取公司信息文件目录。"""
return self._conn.execute(GetCompanyInfoCategoryCmd(market, code))
def get_company_info_content(
self, market: Market, code: str, filename: str, offset: int, length: int
) -> str:
"""读取公司信息文本。"""
return self._conn.execute(
GetCompanyInfoContentCmd(market, code, filename, offset, length)
)
# ============================================================
# 异步客户端
# ============================================================
class AsyncTdxClient:
"""异步通达信行情客户端(asyncio)。
使用示例::
async with AsyncTdxClient("180.153.18.170") as c:
bars = await c.get_security_bars(Market.SH, "600000", KlineCategory.DAY, 0, 100)
"""
def __init__(
self,
host: str = _DEFAULT_HOST,
port: int = _DEFAULT_PORT,
timeout: float = 15.0,
) -> None:
self._conn = AsyncTdxConnection(host, port, timeout)
async def connect(self) -> None:
await self._conn.connect()
async def close(self) -> None:
await self._conn.close()
async def __aenter__(self) -> "AsyncTdxClient":
await self.connect()
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
await self.close()
async def get_security_count(self, market: Market) -> int:
return await self._conn.execute(GetSecurityCountCmd(market))
async def get_security_list(self, market: Market, start: int) -> list[SecurityInfo]:
return await self._conn.execute(GetSecurityListCmd(market, start))
async def get_security_quotes(
self, stocks: list[tuple[Market, str]]
) -> list[SecurityQuote]:
return await self._conn.execute(GetSecurityQuotesCmd(stocks))
async def get_security_bars(
self,
market: Market,
code: str,
category: KlineCategory,
start: int,
count: int = 800,
) -> list[SecurityBar]:
return await self._conn.execute(GetSecurityBarsCmd(market, code, category, start, count))
async def get_index_bars(
self,
market: Market,
code: str,
category: KlineCategory,
start: int,
count: int = 800,
) -> list[SecurityBar]:
return await self._conn.execute(GetIndexBarsCmd(market, code, category, start, count))
async def get_minute_time_data(self, market: Market, code: str) -> list[MinuteBar]:
return await self._conn.execute(GetMinuteTimeDataCmd(market, code))
async def get_history_minute_time_data(
self, market: Market, code: str, date: int
) -> list[MinuteBar]:
return await self._conn.execute(GetHistoryMinuteTimeDataCmd(market, code, date))
async def get_transaction_data(
self, market: Market, code: str, start: int, count: int = 800
) -> list[TransactionRecord]:
return await self._conn.execute(GetTransactionDataCmd(market, code, start, count))
async def get_history_transaction_data(
self, market: Market, code: str, date: int, start: int, count: int = 800
) -> list[TransactionRecord]:
return await self._conn.execute(
GetHistoryTransactionDataCmd(market, code, date, start, count)
)
async def get_xdxr_info(self, market: Market, code: str) -> list[XdxrRecord]:
return await self._conn.execute(GetXdxrInfoCmd(market, code))
async def get_finance_info(self, market: Market, code: str) -> FinanceInfo:
return await self._conn.execute(GetFinanceInfoCmd(market, code))
async def get_company_info_category(
self, market: Market, code: str
) -> list[CompanyInfoCategory]:
return await self._conn.execute(GetCompanyInfoCategoryCmd(market, code))
async def get_company_info_content(
self, market: Market, code: str, filename: str, offset: int, length: int
) -> str:
return await self._conn.execute(
GetCompanyInfoContentCmd(market, code, filename, offset, length)
)
+18
View File
@@ -0,0 +1,18 @@
from .datetime_ import get_datetime, get_datetime_day, get_datetime_minute, get_time
from .frame import HEADER_SIZE, FrameHeader, decompress_body, parse_header
from .price import get_price, put_price
from .volume import get_volume
__all__ = [
"get_price",
"put_price",
"get_volume",
"get_datetime",
"get_datetime_minute",
"get_datetime_day",
"get_time",
"parse_header",
"decompress_body",
"FrameHeader",
"HEADER_SIZE",
]
+68
View File
@@ -0,0 +1,68 @@
"""日期时间解码(通达信 TCP 两种格式)。
分钟级(category < 4 或 == 7/8):4 字节 = 2 字节压缩日期 + 2 字节分钟数
zipday: year=(>>11)+2004, month=(% 2048)//100, day=(% 2048)%100
tminutes: hour=//60, minute=%60
日线及以上(其余 category):4 字节 YYYYMMDD 整数
"""
import struct
def get_datetime_minute(
data: bytes | bytearray, pos: int
) -> tuple[int, int, int, int, int, int]:
"""解析分钟级时间戳(4 字节)。
Returns:
(year, month, day, hour, minute, new_pos)
"""
zipday, tminutes = struct.unpack_from("<HH", data, pos)
year = (zipday >> 11) + 2004
month = (zipday % 2048) // 100
day = (zipday % 2048) % 100
hour = tminutes // 60
minute = tminutes % 60
return year, month, day, hour, minute, pos + 4
def get_datetime_day(
data: bytes | bytearray, pos: int
) -> tuple[int, int, int, int]:
"""解析日期(4 字节 YYYYMMDD)。
Returns:
(year, month, day, new_pos)
"""
(zipday,) = struct.unpack_from("<I", data, pos)
year = zipday // 10000
month = (zipday % 10000) // 100
day = zipday % 100
return year, month, day, pos + 4
def get_datetime(
category: int, data: bytes | bytearray, pos: int
) -> tuple[int, int, int, int, int, int]:
"""根据 KlineCategory 选择解析格式。
Returns:
(year, month, day, hour, minute, new_pos)
日线及以上时 hour=15, minute=0(收盘时间,与 pytdx 保持一致)
"""
if category < 4 or category in (7, 8):
return get_datetime_minute(data, pos)
else:
year, month, day, new_pos = get_datetime_day(data, pos)
return year, month, day, 15, 0, new_pos
def get_time(data: bytes | bytearray, pos: int) -> tuple[int, int, int]:
"""解析 2 字节时间(分钟数)。
Returns:
(hour, minute, new_pos)
"""
(tminutes,) = struct.unpack_from("<H", data, pos)
return tminutes // 60, tminutes % 60, pos + 2
+42
View File
@@ -0,0 +1,42 @@
"""响应帧头解析与 zlib 解压。
响应帧格式(16 字节固定头 + body):
struct "<IIIHH"
偏移 0: I (4字节) — 未知
偏移 4: I (4字节) — 未知
偏移 8: I (4字节) — 未知
偏移 12: H (2字节) — zipsizebody 实际长度)
偏移 14: H (2字节) — unzipsize(解压后长度;等于 zipsize 表示未压缩)
"""
import struct
import zlib
from dataclasses import dataclass
HEADER_SIZE: int = 16
_HEADER_FMT = "<IIIHH"
@dataclass(frozen=True)
class FrameHeader:
unknown_0: int
unknown_1: int
unknown_2: int
zipsize: int
unzipsize: int
def parse_header(buf: bytes) -> FrameHeader:
"""解析 16 字节响应帧头。"""
u0, u1, u2, zipsize, unzipsize = struct.unpack_from(_HEADER_FMT, buf)
return FrameHeader(u0, u1, u2, zipsize, unzipsize)
def decompress_body(header: FrameHeader, raw_body: bytes) -> bytes:
"""按需 zlib 解压 body。
zipsize == unzipsize 时直接返回原始字节;否则 zlib 解压。
"""
if header.zipsize == header.unzipsize:
return raw_body
return zlib.decompress(raw_body)
+58
View File
@@ -0,0 +1,58 @@
"""变长有符号整数编解码(通达信 TCP 价格编码)。
协议规则:
- 首字节:bit7=继续标记,bit6=符号(1=负),bit5~0=低6位数据
- 后续字节:bit7=继续标记,bit6~0=7位数据
- 所有数据位低位在前(小端 bit 顺序)
典型用途:价格差分、成交量差分、买卖档位数量。
"""
def get_price(data: bytes | bytearray, pos: int) -> tuple[int, int]:
"""解码一个变长有符号整数。
Returns:
(value, new_pos)
"""
bit_shift = 6
b = data[pos]
value = b & 0x3F
negative = bool(b & 0x40)
if b & 0x80:
while True:
pos += 1
b = data[pos]
value |= (b & 0x7F) << bit_shift
bit_shift += 7
if not (b & 0x80):
break
pos += 1
return (-value if negative else value), pos
def put_price(value: int) -> bytes:
"""将整数编码为变长格式(用于构造请求包)。"""
negative = value < 0
value = abs(value)
# 首字节:低6位数据 + 符号位
first = value & 0x3F
value >>= 6
if negative:
first |= 0x40
if value:
first |= 0x80
result = bytearray([first])
while value:
b = value & 0x7F
value >>= 7
if value:
b |= 0x80
result.append(b)
return bytes(result)
+56
View File
@@ -0,0 +1,56 @@
"""通达信 4 字节自定义浮点格式解码(成交量专用)。
格式:4 字节小端 uint32,分三段:
[3] logpoint — 指数部分
[2] hleax — 高精度部分
[1] lheax — 中精度部分
[0] lleax — 低精度部分
警告:此函数专为成交量设计,不可用于价格字段(pytdx Bug #3)。
"""
import struct
def get_volume(data: bytes | bytearray, pos: int) -> tuple[float, int]:
"""从 data[pos:pos+4] 解码成交量。
Returns:
(volume_float, new_pos)
"""
(ivol,) = struct.unpack_from("<I", data, pos)
return _decode_volume(ivol), pos + 4
def _decode_volume(ivol: int) -> float:
if ivol == 0:
return 0.0
logpoint = (ivol >> 24) & 0xFF
hleax = (ivol >> 16) & 0xFF
lheax = (ivol >> 8) & 0xFF
lleax = ivol & 0xFF
exp = logpoint * 2 - 0x7F
base = _pow2(exp)
exp_h = logpoint * 2 - 0x86
if hleax > 0x80:
hi = _pow2(exp_h) * 128 + (hleax & 0x7F) * _pow2(exp_h + 1)
else:
hi = _pow2(exp_h) * hleax
mid = _pow2(logpoint * 2 - 0x8E) * lheax
lo = _pow2(logpoint * 2 - 0x96) * lleax
if hleax & 0x80:
mid *= 2.0
lo *= 2.0
return base + hi + mid + lo
def _pow2(exp: int) -> float:
if exp >= 0:
return float(1 << exp) if exp < 63 else 2.0 ** exp
return 1.0 / (1 << (-exp)) if -exp < 63 else 2.0 ** exp
+10
View File
@@ -0,0 +1,10 @@
from .base import BaseCommand
from .setup import SETUP_CMD1, SETUP_CMD2, SETUP_CMD3, SETUP_COMMANDS
__all__ = [
"BaseCommand",
"SETUP_CMD1",
"SETUP_CMD2",
"SETUP_CMD3",
"SETUP_COMMANDS",
]
+29
View File
@@ -0,0 +1,29 @@
"""命令基类:只含请求构造与响应解析,不含任何 IO。
transport 层负责:发送请求、接收帧头、接收 body、解压,
然后调用 command.parse_response(body) 得到结果。
"""
from abc import ABC, abstractmethod
from typing import Generic, TypeVar
T = TypeVar("T")
class BaseCommand(ABC, Generic[T]):
"""所有行情命令的基类。
子类实现:
build_request() → 返回要发送的原始字节
parse_response() → 从解压后的 body 返回强类型结果
"""
@abstractmethod
def build_request(self) -> bytes:
"""构造请求包(含完整帧头)。"""
...
@abstractmethod
def parse_response(self, body: bytes) -> T:
"""解析解压后的响应 body,返回强类型结果。"""
...
+76
View File
@@ -0,0 +1,76 @@
"""公司信息目录与内容命令。"""
import struct
from ..models.enums import Market
from ..models.finance import CompanyInfoCategory
from .base import BaseCommand
class GetCompanyInfoCategoryCmd(BaseCommand[list[CompanyInfoCategory]]):
"""获取公司信息文件目录(文件名列表 + 每段偏移/长度)。"""
def __init__(self, market: Market, code: str) -> None:
self.market = market
self.code = code.encode("utf-8")
def build_request(self) -> bytes:
header = bytes.fromhex("0c0f109b00010e000e00cf02".replace(" ", ""))
return header + struct.pack("<H6sI", int(self.market), self.code, 0)
def parse_response(self, body: bytes) -> list[CompanyInfoCategory]:
if len(body) < 2:
return []
(num,) = struct.unpack_from("<H", body, 0)
pos = 2
results: list[CompanyInfoCategory] = []
# 每条记录:64字节name + 80字节filename + 4字节start + 4字节length = 152字节
_RECORD_SIZE = 152
for _ in range(num):
if pos + _RECORD_SIZE > len(body):
break
name_b, filename_b, start, length = struct.unpack_from("<64s80sII", body, pos)
pos += _RECORD_SIZE
def _decode(b: bytes) -> str:
nul = b.find(b"\x00")
raw = b[:nul] if nul != -1 else b
return raw.decode("gbk", errors="replace")
results.append(CompanyInfoCategory(
filename=_decode(filename_b),
start=start,
length=length,
))
return results
class GetCompanyInfoContentCmd(BaseCommand[str]):
"""按文件名、偏移、长度读取公司信息文本(GBK 编码)。"""
def __init__(
self, market: Market, code: str, filename: str, offset: int, length: int
) -> None:
self.market = market
self.code = code.encode("utf-8")
self.filename = filename.encode("gbk")
self.offset = offset
self.length = length
def build_request(self) -> bytes:
fname_padded = (self.filename + b"\x00" * 80)[:80]
header = bytes.fromhex("0c07109c0001680068 00d002".replace(" ", ""))
return header + struct.pack(
"<H6sH80sIII",
int(self.market), self.code, 0, fname_padded, self.offset, self.length, 0,
)
def parse_response(self, body: bytes) -> str:
# 前12字节:10字节未知 + 2字节长度
if len(body) < 12:
return ""
_, length = struct.unpack_from("<10sH", body, 0)
content = body[12 : 12 + length]
return content.decode("gbk", errors="replace")
+84
View File
@@ -0,0 +1,84 @@
"""最新财务数据命令。"""
import struct
from ..models.enums import Market
from ..models.finance import FinanceInfo
from .base import BaseCommand
# 财务字段 struct 格式:1f + 2H + 2I + 30f
_FIN_FMT = "<fHHII" + "f" * 30
_FIN_SIZE = struct.calcsize(_FIN_FMT)
class GetFinanceInfoCmd(BaseCommand[FinanceInfo]):
"""获取单只股票最新财务数据。"""
def __init__(self, market: Market, code: str) -> None:
self.market = market
self.code = code.encode("utf-8")
def build_request(self) -> bytes:
header = bytes.fromhex("0c1f18760001 0b000b001000 0100".replace(" ", ""))
return header + struct.pack("<B6s", int(self.market), self.code)
def parse_response(self, body: bytes) -> FinanceInfo:
pos = 2 # 跳过前2字节(记录数)
market_b, code_b = struct.unpack_from("<B6s", body, pos)
pos += 7
fields = struct.unpack_from(_FIN_FMT, body, pos)
(
liutong_guben, province, industry, updated_date, ipo_date,
zong_guben, guojia_gu, faqiren_faren_gu, faren_gu, b_gu, h_gu, zhigong_gu,
zong_zichan, liudong_zichan, guding_zichan, wuxing_zichan,
gudong_renshu,
liudong_fuzhai, changqi_fuzhai, ziben_gongjijin, jing_zichan,
zhuying_shouru, zhuying_lirun, yingshou_zhangkuan, yingye_lirun,
touzi_shouyu, jingying_xianjinliu, zong_xianjinliu,
cunhuo, lirun_zonghe, shuihou_lirun, jing_lirun, weifen_lirun,
meigujing_zichan, reserve2,
) = fields
_SCALE = 10000.0 # 财务数据单位:万元/万股
return FinanceInfo(
market=Market(market_b),
code=code_b.decode("utf-8").rstrip("\x00"),
liutong_guben=liutong_guben * _SCALE,
zong_guben=zong_guben * _SCALE,
guojia_gu=guojia_gu * _SCALE,
faqiren_faren_gu=faqiren_faren_gu * _SCALE,
faren_gu=faren_gu * _SCALE,
b_gu=b_gu * _SCALE,
h_gu=h_gu * _SCALE,
zhigong_gu=zhigong_gu * _SCALE,
province=province,
industry=industry,
updated_date=updated_date,
ipo_date=ipo_date,
gudong_renshu=gudong_renshu,
zong_zichan=zong_zichan * _SCALE,
liudong_zichan=liudong_zichan * _SCALE,
guding_zichan=guding_zichan * _SCALE,
wuxing_zichan=wuxing_zichan * _SCALE,
liudong_fuzhai=liudong_fuzhai * _SCALE,
changqi_fuzhai=changqi_fuzhai * _SCALE,
ziben_gongjijin=ziben_gongjijin * _SCALE,
jing_zichan=jing_zichan * _SCALE,
zhuying_shouru=zhuying_shouru * _SCALE,
zhuying_lirun=zhuying_lirun * _SCALE,
yingshou_zhangkuan=yingshou_zhangkuan * _SCALE,
yingye_lirun=yingye_lirun * _SCALE,
touzi_shouyu=touzi_shouyu * _SCALE,
jingying_xianjinliu=jingying_xianjinliu * _SCALE,
zong_xianjinliu=zong_xianjinliu * _SCALE,
cunhuo=cunhuo * _SCALE,
lirun_zonghe=lirun_zonghe * _SCALE,
shuihou_lirun=shuihou_lirun * _SCALE,
jing_lirun=jing_lirun * _SCALE,
weifen_lirun=weifen_lirun * _SCALE,
meigujing_zichan=meigujing_zichan,
reserve2=reserve2,
_raw=body,
)
+69
View File
@@ -0,0 +1,69 @@
"""今日分时 / 历史分时数据命令。
unknown_1 字段:pytdx 中被完全丢弃,保留供分析(疑似均价)。
"""
import struct
from ..codec.price import get_price
from ..models.enums import Market
from ..models.timeseries import MinuteBar
from .base import BaseCommand
class GetMinuteTimeDataCmd(BaseCommand[list[MinuteBar]]):
"""获取今日分时数据(全天 240 条)。"""
def __init__(self, market: Market, code: str) -> None:
self.market = market
self.code = code.encode("utf-8")
def build_request(self) -> bytes:
header = bytes.fromhex("0c1b08000101 0e000e001d05".replace(" ", ""))
return header + struct.pack("<H6sI", int(self.market), self.code, 0)
def parse_response(self, body: bytes) -> list[MinuteBar]:
return _parse_minute_body(body, skip=4)
class GetHistoryMinuteTimeDataCmd(BaseCommand[list[MinuteBar]]):
"""获取历史某日分时数据(date 格式 YYYYMMDD)。"""
def __init__(self, market: Market, code: str, date: int) -> None:
self.market = market
self.code = code.encode("utf-8")
self.date = date
def build_request(self) -> bytes:
# 历史分时:header + pack("<IB6s", date, market, code)
header = bytes.fromhex("0c013000010 10d000d00b40f".replace(" ", ""))
return header + struct.pack("<IB6s", self.date, int(self.market), self.code)
def parse_response(self, body: bytes) -> list[MinuteBar]:
# 历史分时:pytdx 中 pos 跳过 6 字节(2 num + 4 未知)
return _parse_minute_body(body, skip=6)
def _parse_minute_body(body: bytes, skip: int = 4) -> list[MinuteBar]:
(num,) = struct.unpack_from("<H", body, 0)
pos = skip # 今日分时 skip=4,历史分时 skip=6
last_price = 0
bars: list[MinuteBar] = []
for _ in range(num):
record_start = pos
price_diff, pos = get_price(body, pos)
unknown_1, pos = get_price(body, pos) # pytdx 原丢弃,保留
vol, pos = get_price(body, pos)
last_price += price_diff
bars.append(
MinuteBar(
price=last_price / 100.0,
vol=vol,
unknown_1=unknown_1,
_raw=body[record_start:pos],
)
)
return bars
+106
View File
@@ -0,0 +1,106 @@
"""获取 K 线数据命令(支持全部周期)。"""
import struct
from ..codec.datetime_ import get_datetime
from ..codec.price import get_price
from ..codec.volume import get_volume
from ..models.bar import SecurityBar
from ..models.enums import KlineCategory, Market
from .base import BaseCommand
class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]):
"""获取指定股票的 K 线数据。
Args:
market: 市场(SH/SZ
code: 6位股票代码(字符串)
category: K线周期
start: 起始行(0 = 最新;分页时递增)
count: 返回条数(最多 800)
"""
def __init__(
self,
market: Market,
code: str,
category: KlineCategory,
start: int,
count: int = 800,
) -> None:
self.market = market
self.code = code.encode("utf-8")
self.category = category
self.start = start
self.count = count
def build_request(self) -> bytes:
return struct.pack(
"<HIHHHH6sHHHHIIH",
0x010C, # 固定
0x01016408, # 固定
0x001C, # 固定(payload 长度)
0x001C, # 固定(payload 长度)
0x052D, # 命令码:K线
int(self.market),
self.code,
int(self.category),
1, # 固定
self.start,
self.count,
0, 0, 0, # 填充
)
def parse_response(self, body: bytes) -> list[SecurityBar]:
(ret_count,) = struct.unpack_from("<H", body, 0)
pos = 2
bars: list[SecurityBar] = []
pre_diff_base = 0
cat = int(self.category)
for _ in range(ret_count):
record_start = pos
year, month, day, hour, minute, pos = get_datetime(cat, body, pos)
open_diff, pos = get_price(body, pos)
close_diff, pos = get_price(body, pos)
high_diff, pos = get_price(body, pos)
low_diff, pos = get_price(body, pos)
vol, pos = get_volume(body, pos)
amount, pos = get_volume(body, pos)
# 差分还原(与 pytdx 完全一致)
open_abs = open_diff + pre_diff_base
close_abs = open_abs + close_diff
high_abs = open_abs + high_diff
low_abs = open_abs + low_diff
pre_diff_base = open_abs + close_diff
bars.append(
SecurityBar(
open=open_abs / 1000.0,
close=close_abs / 1000.0,
high=high_abs / 1000.0,
low=low_abs / 1000.0,
vol=vol,
amount=amount,
year=year,
month=month,
day=day,
hour=hour,
minute=minute,
_raw=body[record_start:pos],
)
)
return bars
class GetIndexBarsCmd(GetSecurityBarsCmd):
"""获取指数 K 线(请求格式与股票 K 线相同,服务器端按指数逻辑处理)。
实际上通达信服务器对股票代码前缀自动判断指数/股票,
此子类仅作语义区分,无额外逻辑。
"""
+24
View File
@@ -0,0 +1,24 @@
"""获取市场股票/证券总数命令。"""
import struct
from ..models.enums import Market
from .base import BaseCommand
class GetSecurityCountCmd(BaseCommand[int]):
"""返回指定市场的证券总数。
心跳命令也可复用此命令(pytdx 用随机 market 发心跳)。
"""
def __init__(self, market: Market) -> None:
self.market = market
def build_request(self) -> bytes:
header = bytes.fromhex("0c0c186c000108000800 4e04".replace(" ", ""))
return header + struct.pack("<H", int(self.market)) + b"\x75\xc7\x33\x01"
def parse_response(self, body: bytes) -> int:
(count,) = struct.unpack_from("<H", body, 0)
return count
+67
View File
@@ -0,0 +1,67 @@
"""获取证券列表命令(每页最多1000条,按 start 分页)。
修复 pytdx Bug #2GBK 解码使用 errors='replace',截断多字节序列不再崩溃。
修复 pytdx Bug #3pre_close 使用 get_price 解码,而非 get_volume。
"""
import struct
from ..codec.price import get_price
from ..models.enums import Market
from ..models.security import SecurityInfo
from .base import BaseCommand
_RECORD_SIZE = 29
class GetSecurityListCmd(BaseCommand[list[SecurityInfo]]):
"""获取指定市场从 start 开始的证券列表。"""
def __init__(self, market: Market, start: int) -> None:
self.market = market
self.start = start
def build_request(self) -> bytes:
header = bytes.fromhex("0c01186401010600060050 04".replace(" ", ""))
return header + struct.pack("<HH", int(self.market), self.start)
def parse_response(self, body: bytes) -> list[SecurityInfo]:
(num,) = struct.unpack_from("<H", body, 0)
pos = 2
results: list[SecurityInfo] = []
for _ in range(num):
raw = body[pos : pos + _RECORD_SIZE]
(
code_bytes,
volunit,
name_bytes,
_unknown1, # 4字节,含义未明
decimal_point,
pre_close_raw,
_unknown2, # 4字节,含义未明
) = struct.unpack("<6sH8s4sBI4s", raw)
code = code_bytes.decode("utf-8", errors="replace").rstrip("\x00")
# Bug #2 修复:errors='replace' 避免截断 GBK 多字节序列时崩溃
name = name_bytes.decode("gbk", errors="replace").rstrip("\x00")
# Bug #3 修复:pre_close 不用 get_volume(成交量解码),
# 而是直接将 uint32 当作价格整数(/ 100
# 实际服务器返回的 pre_close_raw 是 price * 100 的整数
pre_close = pre_close_raw / 100.0
results.append(
SecurityInfo(
market=self.market,
code=code,
name=name,
volunit=volunit,
decimal_point=decimal_point,
pre_close=pre_close,
_raw=raw,
)
)
pos += _RECORD_SIZE
return results
+194
View File
@@ -0,0 +1,194 @@
"""获取实时五档行情命令(最多 80 只/次)。
所有未知字段(unknown_N)保留原始解析值,供逆向分析。
"""
import struct
from ..codec.price import get_price
from ..codec.volume import get_volume
from ..models.enums import Market
from ..models.quote import SecurityQuote
from .base import BaseCommand
def _format_server_time(raw: int) -> str:
"""将 reversed_bytes0 整数转换为 HH:MM:SS.mmm 字符串。
方法来自 pytdx issue #187。raw 为 14999212 → "14:59:57.163"
"""
s = str(raw)
if len(s) < 6:
return s
# 最后6位:前两位=秒,后四位=毫秒的某种编码
time_part = s[:-6] + ":"
last6 = int(s[-6:])
if int(s[-6:-4]) < 60:
time_part += s[-6:-4] + ":"
time_part += f"{last6 % 10000 * 60 / 10000.0:06.3f}"
else:
mins = last6 * 60 // 1000000
secs = (last6 * 60 % 1000000) * 60 / 1000000.0
time_part += f"{mins:02d}:{secs:06.3f}"
return time_part
class GetSecurityQuotesCmd(BaseCommand[list[SecurityQuote]]):
"""批量获取实时行情(最多 80 只)。
Args:
stocks: [(market, code), ...] 列表
"""
def __init__(self, stocks: list[tuple[Market, str]]) -> None:
if not stocks:
raise ValueError("stocks 不能为空")
if len(stocks) > 80:
raise ValueError("单次最多查询 80 只股票")
self.stocks = stocks
def build_request(self) -> bytes:
n = len(self.stocks)
payload_len = n * 7 + 12
header = struct.pack(
"<HIHHIIHH",
0x010C,
0x02006320,
payload_len,
payload_len,
0x0005053E,
0,
0,
n,
)
body = bytearray(header)
for market, code in self.stocks:
body.extend(struct.pack("<B6s", int(market), code.encode("utf-8")))
return bytes(body)
def parse_response(self, body: bytes) -> list[SecurityQuote]:
pos = 0
# pytdx 跳过前2字节(b1 cb 魔数)
pos += 2
(num,) = struct.unpack_from("<H", body, pos)
pos += 2
results: list[SecurityQuote] = []
for _ in range(num):
record_start = pos
market_b, code_b, active1 = struct.unpack_from("<B6sH", body, pos)
pos += 9
price_raw, pos = get_price(body, pos)
last_close_diff, pos = get_price(body, pos)
open_diff, pos = get_price(body, pos)
high_diff, pos = get_price(body, pos)
low_diff, pos = get_price(body, pos)
# unknown_0: 服务器时间戳原始整数(get_price 解码)
unknown_0, pos = get_price(body, pos)
# unknown_1: 通常等于 -price_rawpytdx 注释推测)
unknown_1, pos = get_price(body, pos)
vol, pos = get_price(body, pos)
cur_vol, pos = get_price(body, pos)
amount_raw, = struct.unpack_from("<I", body, pos)
amount, _ = get_volume(body, pos)
pos += 4
s_vol, pos = get_price(body, pos)
b_vol, pos = get_price(body, pos)
unknown_2, pos = get_price(body, pos)
unknown_3, pos = get_price(body, pos)
# 五档买盘
bid1_d, pos = get_price(body, pos)
ask1_d, pos = get_price(body, pos)
bv1, pos = get_price(body, pos)
av1, pos = get_price(body, pos)
bid2_d, pos = get_price(body, pos)
ask2_d, pos = get_price(body, pos)
bv2, pos = get_price(body, pos)
av2, pos = get_price(body, pos)
bid3_d, pos = get_price(body, pos)
ask3_d, pos = get_price(body, pos)
bv3, pos = get_price(body, pos)
av3, pos = get_price(body, pos)
bid4_d, pos = get_price(body, pos)
ask4_d, pos = get_price(body, pos)
bv4, pos = get_price(body, pos)
av4, pos = get_price(body, pos)
bid5_d, pos = get_price(body, pos)
ask5_d, pos = get_price(body, pos)
bv5, pos = get_price(body, pos)
av5, pos = get_price(body, pos)
# 尾部:2字节 H + 4个 get_price + 2字节 h + 2字节 H
(unknown_4,) = struct.unpack_from("<H", body, pos)
pos += 2
unknown_5, pos = get_price(body, pos)
unknown_6, pos = get_price(body, pos)
unknown_7, pos = get_price(body, pos)
unknown_8, pos = get_price(body, pos)
rise_speed_raw, active2 = struct.unpack_from("<hH", body, pos)
pos += 4
p = price_raw / 100.0
results.append(
SecurityQuote(
market=Market(market_b),
code=code_b.decode("utf-8").rstrip("\x00"),
price=p,
pre_close=(price_raw + last_close_diff) / 100.0,
open=(price_raw + open_diff) / 100.0,
high=(price_raw + high_diff) / 100.0,
low=(price_raw + low_diff) / 100.0,
vol=float(vol),
cur_vol=float(cur_vol),
amount=amount,
s_vol=float(s_vol),
b_vol=float(b_vol),
active1=active1,
active2=active2,
bid1=(price_raw + bid1_d) / 100.0,
bid_vol1=float(bv1),
bid2=(price_raw + bid2_d) / 100.0,
bid_vol2=float(bv2),
bid3=(price_raw + bid3_d) / 100.0,
bid_vol3=float(bv3),
bid4=(price_raw + bid4_d) / 100.0,
bid_vol4=float(bv4),
bid5=(price_raw + bid5_d) / 100.0,
bid_vol5=float(bv5),
ask1=(price_raw + ask1_d) / 100.0,
ask_vol1=float(av1),
ask2=(price_raw + ask2_d) / 100.0,
ask_vol2=float(av2),
ask3=(price_raw + ask3_d) / 100.0,
ask_vol3=float(av3),
ask4=(price_raw + ask4_d) / 100.0,
ask_vol4=float(av4),
ask5=(price_raw + ask5_d) / 100.0,
ask_vol5=float(av5),
rise_speed=rise_speed_raw / 100.0,
unknown_2=unknown_2,
unknown_3=unknown_3,
unknown_5=unknown_5,
unknown_6=unknown_6,
unknown_7=unknown_7,
unknown_8=unknown_8,
server_time=_format_server_time(unknown_0),
_raw=body[record_start:pos],
)
)
return results
+18
View File
@@ -0,0 +1,18 @@
"""握手命令原始字节(从 pytdx/parser/setup_commands.py 移植,已在真实服务器验证)。
连接建立后必须按序发送三条握手命令,每条均需读取并丢弃响应。
"""
from typing import Final
# 从 pytdx 源码原文复制,去除空格
SETUP_CMD1: Final[bytes] = bytes.fromhex("0c0218930001030003000d0001")
SETUP_CMD2: Final[bytes] = bytes.fromhex("0c0218940001030003000d0002")
SETUP_CMD3: Final[bytes] = bytes.fromhex(
"0c031899000120002000db0f"
"d5d0c9ccd6a4a8af0000008f"
"c22540130000d500c9ccbdf0"
"d7ea00000002"
)
SETUP_COMMANDS: Final[tuple[bytes, ...]] = (SETUP_CMD1, SETUP_CMD2, SETUP_CMD3)
+104
View File
@@ -0,0 +1,104 @@
"""逐笔成交命令(当日 + 历史)。
修复 pytdx Bug #4:保留原被 _ 丢弃的最后一个字段为 unknown_last。
"""
import struct
from ..codec.datetime_ import get_time
from ..codec.price import get_price
from ..models.enums import Market
from ..models.timeseries import TransactionRecord
from .base import BaseCommand
class GetTransactionDataCmd(BaseCommand[list[TransactionRecord]]):
"""获取当日逐笔成交(分页,每次最多 800 条)。"""
def __init__(self, market: Market, code: str, start: int, count: int = 800) -> None:
self.market = market
self.code = code.encode("utf-8")
self.start = start
self.count = count
def build_request(self) -> bytes:
header = bytes.fromhex("0c170801010 10e000e00c50f".replace(" ", ""))
return header + struct.pack("<H6sHH", int(self.market), self.code, self.start, self.count)
def parse_response(self, body: bytes) -> list[TransactionRecord]:
return _parse_transaction_body(body, skip=2)
class GetHistoryTransactionDataCmd(BaseCommand[list[TransactionRecord]]):
"""获取历史某日逐笔成交(date 格式 YYYYMMDD,分页)。"""
def __init__(
self, market: Market, code: str, date: int, start: int, count: int = 800
) -> None:
self.market = market
self.code = code.encode("utf-8")
self.date = date
self.start = start
self.count = count
def build_request(self) -> bytes:
# 历史逐笔:header + pack("<IH6sHH", date, market, code, start, count)
header = bytes.fromhex("0c013001000112001200b50f".replace(" ", ""))
return header + struct.pack(
"<IH6sHH", self.date, int(self.market), self.code, self.start, self.count
)
def parse_response(self, body: bytes) -> list[TransactionRecord]:
# 历史逐笔:num(2) + 4字节填充;无"成交笔数"字段
return _parse_history_transaction_body(body)
def _parse_transaction_body(body: bytes) -> list[TransactionRecord]:
"""当日逐笔:time + price + vol + num_orders + buyorsell + unknown"""
(num,) = struct.unpack_from("<H", body, 0)
pos = 2
last_price = 0
records: list[TransactionRecord] = []
for _ in range(num):
record_start = pos
hour, minute, pos = get_time(body, pos)
price_diff, pos = get_price(body, pos)
vol, pos = get_price(body, pos)
_num_orders, pos = get_price(body, pos) # 成交笔数(当日独有)
buyorsell, pos = get_price(body, pos)
unknown_last, pos = get_price(body, pos) # Bug #4 修复:不再丢弃
last_price += price_diff
records.append(TransactionRecord(
hour=hour, minute=minute,
price=last_price / 100.0, vol=vol, buyorsell=buyorsell,
unknown_last=unknown_last, _raw=body[record_start:pos],
))
return records
def _parse_history_transaction_body(body: bytes) -> list[TransactionRecord]:
"""历史逐笔:num(2) + skip(4) + [time + price + vol + buyorsell + unknown]"""
(num,) = struct.unpack_from("<H", body, 0)
pos = 6 # 2(num) + 4(skip)
last_price = 0
records: list[TransactionRecord] = []
for _ in range(num):
record_start = pos
hour, minute, pos = get_time(body, pos)
price_diff, pos = get_price(body, pos)
vol, pos = get_price(body, pos)
buyorsell, pos = get_price(body, pos) # 历史无 num_orders
unknown_last, pos = get_price(body, pos)
last_price += price_diff
records.append(TransactionRecord(
hour=hour, minute=minute,
price=last_price / 100.0, vol=vol, buyorsell=buyorsell,
unknown_last=unknown_last, _raw=body[record_start:pos],
))
return records
+99
View File
@@ -0,0 +1,99 @@
"""除权除息信息命令。
修复 pytdx Bug #1:循环内从正确的 pos 位置读取 market/code
不再始终读取 body[:7]。
"""
import struct
from ..codec.datetime_ import get_datetime
from ..models.enums import Market
from ..models.finance import XDXR_CATEGORY_NAMES, XdxrRecord
from .base import BaseCommand
class GetXdxrInfoCmd(BaseCommand[list[XdxrRecord]]):
"""获取除权除息历史记录。"""
def __init__(self, market: Market, code: str) -> None:
self.market = market
self.code = code.encode("utf-8")
def build_request(self) -> bytes:
header = bytes.fromhex("0c1f18760001 0b000b000f000100".replace(" ", ""))
return header + struct.pack("<B6s", int(self.market), self.code)
def parse_response(self, body: bytes) -> list[XdxrRecord]:
if len(body) < 11:
return []
pos = 9 # 跳过9字节(market+code+未知)
(num,) = struct.unpack_from("<H", body, pos)
pos += 2
records: list[XdxrRecord] = []
for _ in range(num):
record_start = pos
# Bug #1 修复:从当前 pos 读,而非 body[:7]
if pos + 7 > len(body):
break
market_b, code_b = struct.unpack_from("<B6s", body, pos)
pos += 7
pos += 1 # 跳过1个未知字节
year, month, day, _hour, _min, pos = get_datetime(9, body, pos)
(category,) = struct.unpack_from("<B", body, pos)
pos += 1
if pos + 16 > len(body):
break
chunk = body[pos : pos + 16]
pos += 16
rec = XdxrRecord(
market=Market(market_b),
code=code_b.decode("utf-8").rstrip("\x00"),
year=year,
month=month,
day=day,
category=category,
name=XDXR_CATEGORY_NAMES.get(category, str(category)),
_raw=body[record_start:pos],
)
if category == 1:
fenhong, peigujia, songzhuangu, peigu = struct.unpack("<ffff", chunk)
rec.fenhong = fenhong
rec.peigujia = peigujia
rec.songzhuangu = songzhuangu
rec.peigu = peigu
elif category in (11, 12):
_, _, suogu, _ = struct.unpack("<IIfI", chunk)
rec.suogu = suogu
elif category in (13, 14):
xingquanjia, _, fenshu, _ = struct.unpack("<fIfI", chunk)
rec.xingquanjia = xingquanjia
rec.fenshu = fenshu
else:
# 股本变动类:4个 uint32,代表前后流通/总股本
ql_raw, qz_raw, hl_raw, hz_raw = struct.unpack("<IIII", chunk)
rec.panqian_liutong = _decode_share_count(ql_raw)
rec.qian_zongguben = _decode_share_count(qz_raw)
rec.panhou_liutong = _decode_share_count(hl_raw)
rec.hou_zongguben = _decode_share_count(hz_raw)
records.append(rec)
return records
def _decode_share_count(raw: int) -> float:
"""股本数量解码(uint32 → 股数)。
pytdx 使用 get_volume 但会产生错误结果(xdxr Bug #1 注释)。
目前保持与服务器原始整数一致,待进一步逆向确认正确解码方式。
"""
return float(raw)
+17
View File
@@ -0,0 +1,17 @@
"""xmtdx 异常层次"""
class TdxError(Exception):
"""所有 xmtdx 异常的基类"""
class TdxConnectionError(TdxError):
"""TCP 连接失败或超时"""
class TdxDecodeError(TdxError):
"""响应报文解析失败"""
class TdxCommandError(TdxError):
"""命令执行失败(服务器返回错误)"""
+25
View File
@@ -0,0 +1,25 @@
from .bar import SecurityBar
from .enums import KlineCategory, Market
from .finance import (
XDXR_CATEGORY_NAMES,
CompanyInfoCategory,
FinanceInfo,
XdxrRecord,
)
from .quote import SecurityQuote
from .security import SecurityInfo
from .timeseries import MinuteBar, TransactionRecord
__all__ = [
"Market",
"KlineCategory",
"SecurityBar",
"SecurityQuote",
"SecurityInfo",
"MinuteBar",
"TransactionRecord",
"XdxrRecord",
"XDXR_CATEGORY_NAMES",
"FinanceInfo",
"CompanyInfoCategory",
]
+28
View File
@@ -0,0 +1,28 @@
"""K 线数据模型"""
from dataclasses import dataclass, field
@dataclass
class SecurityBar:
"""单根 K 线(适用于 1m/5m/15m/30m/60m/日/周/月/季/年)"""
open: float
close: float
high: float
low: float
vol: float # 成交量(股)
amount: float # 成交额(元)
year: int
month: int
day: int
hour: int
minute: int
# 原始字节,供字段逆向分析使用
_raw: bytes = field(default=b"", repr=False, compare=False)
@property
def datetime_str(self) -> str:
return f"{self.year}-{self.month:02d}-{self.day:02d} {self.hour:02d}:{self.minute:02d}"
+23
View File
@@ -0,0 +1,23 @@
"""市场代码与 K 线周期枚举"""
from enum import IntEnum
class Market(IntEnum):
SZ = 0 # 深圳
SH = 1 # 上海
class KlineCategory(IntEnum):
MIN_5 = 0
MIN_15 = 1
MIN_30 = 2
MIN_60 = 3
DAY = 4
WEEK = 5
MONTH = 6
MIN_1 = 7
MIN_3 = 8 # 通达信内部用,实际同 MIN_1
YEAR = 9
SEASON = 10
YEAR_ALT = 11
+127
View File
@@ -0,0 +1,127 @@
"""财务与公司信息模型"""
from dataclasses import dataclass, field
from .enums import Market
@dataclass
class XdxrRecord:
"""除权除息记录(一只股票可有多条)
pytdx Bug #1 已修复:循环内不再从 body[:7] 读 market/code
而是从当前 pos 正确读取。
"""
market: Market
code: str
year: int
month: int
day: int
category: int # 事件类型(见下方 CATEGORY_NAMES
name: str # 事件类型名称
# category == 1(除权除息)
fenhong: float | None = None # 每股分红(元)
peigujia: float | None = None # 配股价(元)
songzhuangu: float | None = None # 送转股比例
peigu: float | None = None # 配股比例
# category in [11, 12](扩缩股)
suogu: float | None = None # 缩股比例
# category in [13, 14](权证)
xingquanjia: float | None = None # 行权价
fenshu: float | None = None # 分数
# category in [2..10](股本变动类)
panqian_liutong: float | None = None # 盘前流通股本
panhou_liutong: float | None = None # 盘后流通股本
qian_zongguben: float | None = None # 前总股本
hou_zongguben: float | None = None # 后总股本
_raw: bytes = field(default=b"", repr=False, compare=False)
XDXR_CATEGORY_NAMES: dict[int, str] = {
1: "除权除息",
2: "送配股上市",
3: "非流通股上市",
4: "未知股本变动",
5: "股本变化",
6: "增发新股",
7: "股份回购",
8: "增发新股上市",
9: "转配股上市",
10: "可转债上市",
11: "扩缩股",
12: "非流通股缩股",
13: "送认购权证",
14: "送认沽权证",
}
@dataclass
class FinanceInfo:
"""最新财务数据(单只股票)"""
market: Market
code: str
# 股本(万股)
liutong_guben: float # 流通股本
zong_guben: float # 总股本
guojia_gu: float # 国家股
faqiren_faren_gu: float # 发起人法人股
faren_gu: float # 法人股
b_gu: float # B股
h_gu: float # H股
zhigong_gu: float # 职工股
# 基本信息
province: int # 所属省份代码
industry: int # 所属行业代码
updated_date: int # 财务更新日期 YYYYMMDD
ipo_date: int # 上市日期 YYYYMMDD
gudong_renshu: float # 股东人数
# 资产负债(元)
zong_zichan: float # 总资产
liudong_zichan: float # 流动资产
guding_zichan: float # 固定资产
wuxing_zichan: float # 无形资产
liudong_fuzhai: float # 流动负债
changqi_fuzhai: float # 长期负债
ziben_gongjijin: float # 资本公积金
jing_zichan: float # 净资产
# 利润(元)
zhuying_shouru: float # 主营收入
zhuying_lirun: float # 主营利润
yingshou_zhangkuan: float # 应收账款
yingye_lirun: float # 营业利润
touzi_shouyu: float # 投资收益
jingying_xianjinliu: float # 经营现金流
zong_xianjinliu: float # 总现金流
cunhuo: float # 存货
lirun_zonghe: float # 利润总额
shuihou_lirun: float # 税后利润
jing_lirun: float # 净利润
weifen_lirun: float # 未分配利润
# 每股指标
meigujing_zichan: float # 每股净资产(原 baoliu1
# 协议保留字段(含义未完全确认)
reserve2: float = field(default=0.0, repr=False) # 原 baoliu2
_raw: bytes = field(default=b"", repr=False, compare=False)
@dataclass
class CompanyInfoCategory:
"""公司信息文件目录条目"""
filename: str # 文件名(如 '600000.txt'
start: int # 内容起始偏移
length: int # 内容长度(字节)
+68
View File
@@ -0,0 +1,68 @@
"""实时行情五档报价模型"""
from dataclasses import dataclass, field
from .enums import Market
@dataclass
class SecurityQuote:
"""单只股票实时五档行情。
带 unknown_ 前缀的字段为协议中尚未明确含义的字段,保留以供逆向分析。
_raw 为该股票记录的原始字节切片。
"""
market: Market
code: str
# 价格
price: float # 现价
pre_close: float # 昨收
open: float # 今开
high: float # 最高
low: float # 最低
# 量额
vol: float # 总成交量(手)
cur_vol: float # 当前成交量
amount: float # 成交额(元)
s_vol: float # 内盘(主动卖)
b_vol: float # 外盘(主动买)
# 活跃度指标(含义来自社区逆向,仅供参考)
active1: int
active2: int
# 买盘五档
bid1: float; bid_vol1: float
bid2: float; bid_vol2: float
bid3: float; bid_vol3: float
bid4: float; bid_vol4: float
bid5: float; bid_vol5: float
# 卖盘五档
ask1: float; ask_vol1: float
ask2: float; ask_vol2: float
ask3: float; ask_vol3: float
ask4: float; ask_vol4: float
ask5: float; ask_vol5: float
# 已确认含义
rise_speed: float # 涨速(原 reversed_bytes9 / 100
# 未知字段:买卖量之后的两个变长整数
unknown_2: int = field(default=0, repr=False) # 原 reversed_bytes2
unknown_3: int = field(default=0, repr=False) # 原 reversed_bytes3
# 未知字段:尾部四个变长整数
unknown_5: int = field(default=0, repr=False) # 原 reversed_bytes5
unknown_6: int = field(default=0, repr=False) # 原 reversed_bytes6
unknown_7: int = field(default=0, repr=False) # 原 reversed_bytes7
unknown_8: int = field(default=0, repr=False) # 原 reversed_bytes8
# 服务器时间字符串(从 unknown_0 原始整数解析,格式 HH:MM:SS.mmm
server_time: str = field(default="", repr=True)
# 原始字节(该股票记录切片)
_raw: bytes = field(default=b"", repr=False, compare=False)
+19
View File
@@ -0,0 +1,19 @@
"""证券基本信息模型"""
from dataclasses import dataclass, field
from .enums import Market
@dataclass
class SecurityInfo:
"""证券列表条目(来自 get_security_list"""
market: Market
code: str
name: str # 股票名称(GBK 解码,截断字节用 replacement char 替代)
volunit: int # 成交量单位(手 = volunit 股)
decimal_point: int # 价格小数位数
pre_close: float # 昨收价(已修复 pytdx Bug #3:改用正确价格解码)
_raw: bytes = field(default=b"", repr=False, compare=False)
+39
View File
@@ -0,0 +1,39 @@
"""分时与逐笔成交模型"""
from dataclasses import dataclass, field
@dataclass
class MinuteBar:
"""今日/历史分时(每分钟一条,共 240 条)
unknown_1: 协议中第二个变长整数,含义未明(疑似均价的编码形式)。
"""
price: float # 价格
vol: int # 成交量
# pytdx 中被完全丢弃的字段,保留以供分析
unknown_1: int = field(default=0, repr=False) # 原 reversed1
_raw: bytes = field(default=b"", repr=False, compare=False)
@dataclass
class TransactionRecord:
"""逐笔成交记录
unknown_last: pytdx 中被 _ 丢弃的最后一个变长整数,保留以供分析。
时间精度仅到分钟(协议限制),unknown_last 可能含秒或序号信息。
"""
hour: int
minute: int
price: float
vol: int
buyorsell: int # 0=卖, 1=买, 2=中性/撮合
# pytdx 中被丢弃的字段
unknown_last: int = field(default=0, repr=False)
_raw: bytes = field(default=b"", repr=False, compare=False)
+4
View File
@@ -0,0 +1,4 @@
from .async_ import AsyncTdxConnection
from .sync import TdxConnection
__all__ = ["TdxConnection", "AsyncTdxConnection"]
+123
View File
@@ -0,0 +1,123 @@
"""异步 TCP 连接(基于 asyncio)。"""
import asyncio
from types import TracebackType
from typing import TYPE_CHECKING, TypeVar
from ..codec.frame import HEADER_SIZE, decompress_body, parse_header
from ..commands.setup import SETUP_COMMANDS
from ..exceptions import TdxConnectionError
if TYPE_CHECKING:
from ..commands.base import BaseCommand
T = TypeVar("T")
_DEFAULT_HOST = "180.153.18.170"
_DEFAULT_PORT = 7709
_DEFAULT_TIMEOUT = 15.0
class AsyncTdxConnection:
"""异步通达信 TCP 连接(asyncio)。
使用示例::
async with AsyncTdxConnection("180.153.18.170") as conn:
result = await conn.execute(SomeCommand(...))
"""
def __init__(
self,
host: str = _DEFAULT_HOST,
port: int = _DEFAULT_PORT,
timeout: float = _DEFAULT_TIMEOUT,
) -> None:
self.host = host
self.port = port
self.timeout = timeout
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
async def connect(self) -> None:
"""建立 TCP 连接并完成握手。"""
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(self.host, self.port),
timeout=self.timeout,
)
except (OSError, asyncio.TimeoutError) as e:
raise TdxConnectionError(f"无法连接 {self.host}:{self.port}: {e}") from e
self._reader = reader
self._writer = writer
await self._send_setup()
async def close(self) -> None:
"""关闭连接。"""
if self._writer is not None:
try:
self._writer.close()
await self._writer.wait_closed()
except OSError:
pass
self._reader = None
self._writer = None
async def execute(self, cmd: "BaseCommand[T]") -> T:
"""执行一条命令(异步版本)。"""
if self._writer is None or self._reader is None:
raise TdxConnectionError("未连接,请先调用 connect()")
request = cmd.build_request()
try:
self._writer.write(request)
await self._writer.drain()
header_buf = await self._recv_exact(HEADER_SIZE)
header = parse_header(header_buf)
raw_body = await self._recv_exact(header.zipsize)
except (OSError, asyncio.IncompleteReadError) as e:
raise TdxConnectionError(f"通信错误: {e}") from e
body = decompress_body(header, raw_body)
return cmd.parse_response(body)
# ------------------------------------------------------------------ #
# context manager
# ------------------------------------------------------------------ #
async def __aenter__(self) -> "AsyncTdxConnection":
await self.connect()
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
await self.close()
# ------------------------------------------------------------------ #
# internals
# ------------------------------------------------------------------ #
async def _send_setup(self) -> None:
"""按序发送三条握手命令并丢弃响应。"""
assert self._writer is not None
assert self._reader is not None
for cmd_bytes in SETUP_COMMANDS:
self._writer.write(cmd_bytes)
await self._writer.drain()
try:
hdr_buf = await asyncio.wait_for(
self._recv_exact(HEADER_SIZE), timeout=5.0
)
hdr = parse_header(hdr_buf)
if hdr.zipsize > 0:
await self._recv_exact(hdr.zipsize)
except (OSError, asyncio.TimeoutError, asyncio.IncompleteReadError):
pass
async def _recv_exact(self, n: int) -> bytes:
"""读满 n 字节。"""
assert self._reader is not None
data = await self._reader.readexactly(n)
return data
+121
View File
@@ -0,0 +1,121 @@
"""同步 TCP 连接(基于 socket)。"""
import socket
from types import TracebackType
from typing import TYPE_CHECKING, TypeVar
from ..codec.frame import HEADER_SIZE, decompress_body, parse_header
from ..commands.setup import SETUP_COMMANDS
from ..exceptions import TdxConnectionError
if TYPE_CHECKING:
from ..commands.base import BaseCommand
T = TypeVar("T")
_DEFAULT_HOST = "180.153.18.170"
_DEFAULT_PORT = 7709
_DEFAULT_TIMEOUT = 15.0
class TdxConnection:
"""同步通达信 TCP 连接。
使用示例::
with TdxConnection("180.153.18.170") as conn:
result = conn.execute(SomeCommand(...))
"""
def __init__(
self,
host: str = _DEFAULT_HOST,
port: int = _DEFAULT_PORT,
timeout: float = _DEFAULT_TIMEOUT,
) -> None:
self.host = host
self.port = port
self.timeout = timeout
self._sock: socket.socket | None = None
def connect(self) -> None:
"""建立 TCP 连接并完成握手(发送3条 setup 命令)。"""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(self.timeout)
try:
sock.connect((self.host, self.port))
except OSError as e:
sock.close()
raise TdxConnectionError(f"无法连接 {self.host}:{self.port}: {e}") from e
self._sock = sock
self._send_setup()
def close(self) -> None:
"""关闭连接。"""
if self._sock is not None:
try:
self._sock.close()
except OSError:
pass
self._sock = None
def execute(self, cmd: "BaseCommand[T]") -> T:
"""执行一条命令:发送请求,接收并解压响应,返回解析结果。"""
if self._sock is None:
raise TdxConnectionError("未连接,请先调用 connect()")
request = cmd.build_request()
try:
self._sock.sendall(request)
header_buf = self._recv_exact(HEADER_SIZE)
header = parse_header(header_buf)
raw_body = self._recv_exact(header.zipsize)
except OSError as e:
raise TdxConnectionError(f"通信错误: {e}") from e
body = decompress_body(header, raw_body)
return cmd.parse_response(body)
# ------------------------------------------------------------------ #
# context manager
# ------------------------------------------------------------------ #
def __enter__(self) -> "TdxConnection":
self.connect()
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self.close()
# ------------------------------------------------------------------ #
# internals
# ------------------------------------------------------------------ #
def _send_setup(self) -> None:
"""按序发送三条握手命令并丢弃响应。"""
assert self._sock is not None
for cmd_bytes in SETUP_COMMANDS:
self._sock.sendall(cmd_bytes)
# 读取并丢弃握手响应
try:
hdr_buf = self._recv_exact(HEADER_SIZE)
hdr = parse_header(hdr_buf)
if hdr.zipsize > 0:
self._recv_exact(hdr.zipsize)
except OSError:
# 部分服务器的握手无响应,忽略错误
pass
def _recv_exact(self, n: int) -> bytes:
"""循环 recv 直到读满 n 字节。"""
assert self._sock is not None
buf = bytearray()
while len(buf) < n:
chunk = self._sock.recv(n - len(buf))
if not chunk:
raise TdxConnectionError("连接被服务器关闭")
buf.extend(chunk)
return bytes(buf)