mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 13:24:18 +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:
+12
@@ -0,0 +1,12 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
*.egg
|
||||
.venv/
|
||||
venv/
|
||||
@@ -0,0 +1,28 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "xmtdx"
|
||||
version = "0.1.0"
|
||||
description = "通达信 TCP 协议 A 股行情数据客户端"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.0", "pytest-cov", "mypy>=1.9", "ruff>=0.4"]
|
||||
pandas = ["pandas>=2.0"]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/xmtdx"]
|
||||
|
||||
[tool.mypy]
|
||||
strict = true
|
||||
python_version = "3.10"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py310"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP"]
|
||||
@@ -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"
|
||||
@@ -0,0 +1,277 @@
|
||||
"""高层行情 API:TdxClient(同步)和 AsyncTdxClient(asyncio)。"""
|
||||
|
||||
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)
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -0,0 +1,42 @@
|
||||
"""响应帧头解析与 zlib 解压。
|
||||
|
||||
响应帧格式(16 字节固定头 + body):
|
||||
struct "<IIIHH"
|
||||
偏移 0: I (4字节) — 未知
|
||||
偏移 4: I (4字节) — 未知
|
||||
偏移 8: I (4字节) — 未知
|
||||
偏移 12: H (2字节) — zipsize(body 实际长度)
|
||||
偏移 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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,返回强类型结果。"""
|
||||
...
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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 线相同,服务器端按指数逻辑处理)。
|
||||
|
||||
实际上通达信服务器对股票代码前缀自动判断指数/股票,
|
||||
此子类仅作语义区分,无额外逻辑。
|
||||
"""
|
||||
@@ -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
|
||||
@@ -0,0 +1,67 @@
|
||||
"""获取证券列表命令(每页最多1000条,按 start 分页)。
|
||||
|
||||
修复 pytdx Bug #2:GBK 解码使用 errors='replace',截断多字节序列不再崩溃。
|
||||
修复 pytdx Bug #3:pre_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
|
||||
@@ -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_raw(pytdx 注释推测)
|
||||
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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""xmtdx 异常层次"""
|
||||
|
||||
|
||||
class TdxError(Exception):
|
||||
"""所有 xmtdx 异常的基类"""
|
||||
|
||||
|
||||
class TdxConnectionError(TdxError):
|
||||
"""TCP 连接失败或超时"""
|
||||
|
||||
|
||||
class TdxDecodeError(TdxError):
|
||||
"""响应报文解析失败"""
|
||||
|
||||
|
||||
class TdxCommandError(TdxError):
|
||||
"""命令执行失败(服务器返回错误)"""
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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 # 内容长度(字节)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,4 @@
|
||||
from .async_ import AsyncTdxConnection
|
||||
from .sync import TdxConnection
|
||||
|
||||
__all__ = ["TdxConnection", "AsyncTdxConnection"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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