mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 18:04:16 +08:00
feat!: rename project from xmtdx to easy-tdx
- Package directory: src/xmtdx/ -> src/easy_tdx/ - Import path: from easy_tdx import ... - pip install easy-tdx - Add LICENSE (MIT) with upstream attribution (pytdx, xmtdx) - Add NOTICE with detailed attribution - Update all examples, tests, scripts, docs - Bump version to 1.0.0 BREAKING CHANGE: import path changed from `xmtdx` to `easy_tdx` Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
9c5672b4d2
commit
50491f9aae
@@ -0,0 +1,78 @@
|
||||
"""easy_tdx — 通达信 TCP 协议 A 股行情数据客户端。
|
||||
|
||||
快速开始::
|
||||
|
||||
from easy_tdx 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 easy_tdx 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 .ex.client import AsyncExTdxClient, ExTdxClient
|
||||
from .ex.models import KNOWN_EX_HOSTS
|
||||
from .exceptions import TdxCommandError, TdxConnectionError, TdxDecodeError, TdxError
|
||||
from .models import (
|
||||
XDXR_CATEGORY_NAMES,
|
||||
CompanyInfoCategory,
|
||||
FinanceInfo,
|
||||
FinancialFileInfo,
|
||||
FinancialRecord,
|
||||
KlineCategory,
|
||||
Market,
|
||||
MinuteBar,
|
||||
SecurityBar,
|
||||
SecurityInfo,
|
||||
SecurityQuote,
|
||||
TransactionRecord,
|
||||
XdxrRecord,
|
||||
)
|
||||
from .transport.sync import CALC_HOSTS, KNOWN_HOSTS, ping_all
|
||||
|
||||
__all__ = [
|
||||
# 客户端
|
||||
"TdxClient",
|
||||
"AsyncTdxClient",
|
||||
# 枚举
|
||||
"Market",
|
||||
"KlineCategory",
|
||||
# 数据模型
|
||||
"SecurityBar",
|
||||
"SecurityQuote",
|
||||
"SecurityInfo",
|
||||
"MinuteBar",
|
||||
"TransactionRecord",
|
||||
"XdxrRecord",
|
||||
"XDXR_CATEGORY_NAMES",
|
||||
"FinanceInfo",
|
||||
"CompanyInfoCategory",
|
||||
"FinancialFileInfo",
|
||||
"FinancialRecord",
|
||||
# 异常
|
||||
"TdxError",
|
||||
"TdxConnectionError",
|
||||
"TdxDecodeError",
|
||||
"TdxCommandError",
|
||||
# 扩展行情
|
||||
"ExTdxClient",
|
||||
"AsyncExTdxClient",
|
||||
"KNOWN_EX_HOSTS",
|
||||
# 工具
|
||||
"ping_all",
|
||||
"KNOWN_HOSTS",
|
||||
"CALC_HOSTS",
|
||||
]
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,48 @@
|
||||
"""二进制解析辅助函数。"""
|
||||
|
||||
import struct
|
||||
from typing import Any
|
||||
|
||||
from .exceptions import TdxDecodeError
|
||||
|
||||
|
||||
def require_bytes(
|
||||
data: bytes | bytearray,
|
||||
pos: int,
|
||||
size: int,
|
||||
context: str,
|
||||
) -> None:
|
||||
"""确保从 pos 起至少还能读取 size 字节。"""
|
||||
if pos < 0:
|
||||
raise TdxDecodeError(f"{context}: 非法偏移 {pos}")
|
||||
end = pos + size
|
||||
if end > len(data):
|
||||
remaining = max(len(data) - pos, 0)
|
||||
raise TdxDecodeError(
|
||||
f"{context}: 数据不足,需要 {size} 字节,偏移 {pos},实际剩余 {remaining} 字节"
|
||||
)
|
||||
|
||||
|
||||
def unpack_from(
|
||||
fmt: str,
|
||||
data: bytes | bytearray,
|
||||
pos: int,
|
||||
context: str,
|
||||
) -> tuple[Any, ...]:
|
||||
"""带边界检查的 struct.unpack_from。"""
|
||||
require_bytes(data, pos, struct.calcsize(fmt), context)
|
||||
try:
|
||||
return struct.unpack_from(fmt, data, pos)
|
||||
except struct.error as e: # pragma: no cover - require_bytes 已覆盖大部分路径
|
||||
raise TdxDecodeError(f"{context}: 解析失败: {e}") from e
|
||||
|
||||
|
||||
def slice_bytes(
|
||||
data: bytes | bytearray,
|
||||
pos: int,
|
||||
size: int,
|
||||
context: str,
|
||||
) -> bytes:
|
||||
"""带边界检查的切片读取。"""
|
||||
require_bytes(data, pos, size, context)
|
||||
return bytes(data[pos : pos + size])
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,69 @@
|
||||
"""板块文件 (.dat) 解析逻辑。"""
|
||||
|
||||
import struct
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..models.finance import TdxBlock
|
||||
|
||||
|
||||
def parse_block_dat(data: bytes, filename: str = "") -> list["TdxBlock"]:
|
||||
"""解析通达信 .dat 板块文件内容。
|
||||
|
||||
格式:
|
||||
Header: 384 字节(跳过)
|
||||
Count: 2 字节 (uint16 LE)
|
||||
Body: 每条记录 2813 字节 (9s + H + H + 2800s)
|
||||
"""
|
||||
from ..models.finance import TdxBlock
|
||||
|
||||
if len(data) < 386:
|
||||
return []
|
||||
|
||||
pos = 384
|
||||
(count,) = struct.unpack("<H", data[pos:pos+2])
|
||||
pos += 2
|
||||
|
||||
results: list[TdxBlock] = []
|
||||
|
||||
# 推断板块分类 (0=行业, 1=地域, 2=概念, 3=风格)
|
||||
category = 0
|
||||
if "zs" in filename:
|
||||
category = 0
|
||||
elif "gn" in filename:
|
||||
category = 2
|
||||
elif "fg" in filename:
|
||||
category = 3
|
||||
|
||||
for _ in range(count):
|
||||
if len(data) < pos + 2813:
|
||||
break
|
||||
|
||||
# 板块元数据 (9 字节名称 + 2 字节股票数 + 2 字节类型)
|
||||
name_b = data[pos:pos+9]
|
||||
stock_count, _type = struct.unpack("<HH", data[pos+9:pos+13])
|
||||
name = name_b.decode("gbk", errors="replace").strip("\x00")
|
||||
|
||||
# 股票代码区 (2800 字节,每只股票 7 字节)
|
||||
codes: list[str] = []
|
||||
codes_start = pos + 13
|
||||
# 安全检查:stock_count 不应超过 400 (2800 / 7)
|
||||
actual_count = min(stock_count, 400)
|
||||
for i in range(actual_count):
|
||||
c_start = codes_start + i * 7
|
||||
c_raw = data[c_start:c_start+7]
|
||||
code = c_raw.decode("ascii", errors="replace").strip("\x00")
|
||||
if code:
|
||||
codes.append(code)
|
||||
|
||||
results.append(TdxBlock(
|
||||
name=name,
|
||||
category=category,
|
||||
count=stock_count,
|
||||
codes=codes,
|
||||
))
|
||||
|
||||
# 跳过整个 2813 字节的记录块
|
||||
pos += 2813
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,69 @@
|
||||
"""日期时间解码(通达信 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 整数
|
||||
"""
|
||||
|
||||
|
||||
from .._binary import unpack_from
|
||||
|
||||
|
||||
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 = unpack_from("<HH", data, pos, "minute datetime")
|
||||
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,) = unpack_from("<I", data, pos, "day datetime")
|
||||
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,) = unpack_from("<H", data, pos, "trade time")
|
||||
return tminutes // 60, tminutes % 60, pos + 2
|
||||
@@ -0,0 +1,101 @@
|
||||
"""专业财务数据解析(tdxfin/gpcw.txt 列表 + .dat 二进制记录)。"""
|
||||
|
||||
import struct
|
||||
|
||||
|
||||
def parse_financial_file_list(data: bytes) -> list[tuple[str, str, int]]:
|
||||
"""解析 tdxfin/gpcw.txt 的内容。
|
||||
|
||||
每行格式: filename,md5hash,filesize
|
||||
|
||||
Returns:
|
||||
[(filename, hash, filesize), ...]
|
||||
"""
|
||||
if not data:
|
||||
return []
|
||||
text = data.decode("utf-8", errors="replace").strip()
|
||||
results: list[tuple[str, str, int]] = []
|
||||
for line in text.split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split(",")
|
||||
if len(parts) >= 3:
|
||||
results.append((parts[0], parts[1], int(parts[2])))
|
||||
return results
|
||||
|
||||
|
||||
def parse_financial_dat(
|
||||
data: bytes,
|
||||
report_date: int = 0,
|
||||
) -> list[tuple[str, int, int, list[float]]]:
|
||||
"""解析 gpcw*.zip 内的 .dat 二进制文件。
|
||||
|
||||
二进制格式(参考 pytdx.crawler.history_financial_crawler):
|
||||
Header: 20 bytes <1h I 1H 3L
|
||||
- unknown: int16 (h)
|
||||
- report_date: uint32 (I)
|
||||
- max_count: uint16 (H) -- 股票索引条目数
|
||||
- unknown1: uint32 (L)
|
||||
- report_size: uint32 (L) -- 每条股票数据字节长度
|
||||
- unknown2: uint32 (L)
|
||||
Index: max_count 条,每条 11 bytes <6s 1c 1L
|
||||
- code: 6 bytes -- 股票代码
|
||||
- market: 1 byte -- 市场标识 (0=SZ, 1=SH)
|
||||
- file_offset: uint32 -- 绝对偏移(从文件开头算)
|
||||
Data: 在 file_offset 位置读取 report_size/4 个 float32
|
||||
|
||||
Args:
|
||||
data: .dat 文件的完整字节
|
||||
report_date: 报告期 YYYYMMDD(从文件名提取,0 则用 header 中的值)
|
||||
|
||||
Returns:
|
||||
[(code, market_byte, report_date, [float, ...]), ...]
|
||||
"""
|
||||
header_fmt = "<1hI1H3L"
|
||||
header_size = struct.calcsize(header_fmt)
|
||||
if len(data) < header_size:
|
||||
return []
|
||||
|
||||
header = struct.unpack(header_fmt, data[:header_size])
|
||||
max_count = header[2]
|
||||
dat_report_date = header[1]
|
||||
report_size = header[4]
|
||||
|
||||
if report_date == 0:
|
||||
report_date = dat_report_date
|
||||
|
||||
num_fields = report_size // 4
|
||||
if num_fields <= 0:
|
||||
return []
|
||||
|
||||
index_fmt = "<6s1c1L"
|
||||
index_size = struct.calcsize(index_fmt)
|
||||
index_base = header_size
|
||||
|
||||
results: list[tuple[str, int, int, list[float]]] = []
|
||||
report_fmt = f"<{num_fields}f"
|
||||
report_pack_size = struct.calcsize(report_fmt)
|
||||
|
||||
for i in range(max_count):
|
||||
idx_pos = index_base + i * index_size
|
||||
if idx_pos + index_size > len(data):
|
||||
break
|
||||
|
||||
code_bytes, market_byte, file_offset = struct.unpack(
|
||||
index_fmt, data[idx_pos : idx_pos + index_size]
|
||||
)
|
||||
code = code_bytes.decode("ascii", errors="replace").rstrip("\x00")
|
||||
|
||||
if not code or file_offset == 0:
|
||||
continue
|
||||
|
||||
# file_offset 是绝对偏移(从文件开头算)
|
||||
data_pos = file_offset
|
||||
if data_pos + report_pack_size > len(data):
|
||||
continue
|
||||
|
||||
floats = list(struct.unpack(report_fmt, data[data_pos : data_pos + report_pack_size]))
|
||||
results.append((code, market_byte, report_date, floats))
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,63 @@
|
||||
"""响应帧头解析与 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 zlib
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .._binary import unpack_from
|
||||
from ..exceptions import TdxDecodeError
|
||||
|
||||
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 = unpack_from(
|
||||
_HEADER_FMT,
|
||||
buf,
|
||||
0,
|
||||
"frame header",
|
||||
)
|
||||
return FrameHeader(u0, u1, u2, zipsize, unzipsize)
|
||||
|
||||
|
||||
def decompress_body(header: FrameHeader, raw_body: bytes) -> bytes:
|
||||
"""按需 zlib 解压 body。
|
||||
|
||||
zipsize == unzipsize 时直接返回原始字节;否则 zlib 解压。
|
||||
"""
|
||||
if len(raw_body) != header.zipsize:
|
||||
raise TdxDecodeError(
|
||||
f"frame body 长度不符: header={header.zipsize}, actual={len(raw_body)}"
|
||||
)
|
||||
if header.zipsize == header.unzipsize:
|
||||
body = raw_body
|
||||
else:
|
||||
try:
|
||||
body = zlib.decompress(raw_body)
|
||||
except zlib.error as e:
|
||||
raise TdxDecodeError(f"frame body zlib 解压失败: {e}") from e
|
||||
|
||||
if len(body) != header.unzipsize:
|
||||
raise TdxDecodeError(
|
||||
f"frame body 解压长度不符: header={header.unzipsize}, actual={len(body)}"
|
||||
)
|
||||
return body
|
||||
@@ -0,0 +1,23 @@
|
||||
"""通达信行业配置文件 (tdxhy.cfg) 解析器。"""
|
||||
|
||||
def parse_tdxhy_cfg(content: bytes) -> dict[str, tuple[str, str]]:
|
||||
"""解析 tdxhy.cfg 字节内容。
|
||||
|
||||
返回字典: { "code": (tdx_industry, sw_industry), ... }
|
||||
"""
|
||||
results = {}
|
||||
try:
|
||||
text = content.decode("gbk", errors="replace")
|
||||
for line in text.splitlines():
|
||||
parts = line.strip().split("|")
|
||||
if len(parts) >= 3:
|
||||
# 格式: 市场|代码|行业1|||行业2
|
||||
# 我们只关心 A 股 6 位代码
|
||||
code = parts[1]
|
||||
if len(code) == 6:
|
||||
tdx_ind = parts[2]
|
||||
sw_ind = parts[5] if len(parts) >= 6 else ""
|
||||
results[code] = (tdx_ind, sw_ind)
|
||||
except Exception:
|
||||
pass
|
||||
return results
|
||||
@@ -0,0 +1,64 @@
|
||||
"""变长有符号整数编解码(通达信 TCP 价格编码)。
|
||||
|
||||
协议规则:
|
||||
- 首字节:bit7=继续标记,bit6=符号(1=负),bit5~0=低6位数据
|
||||
- 后续字节:bit7=继续标记,bit6~0=7位数据
|
||||
- 所有数据位低位在前(小端 bit 顺序)
|
||||
|
||||
典型用途:价格差分、成交量差分、买卖档位数量。
|
||||
"""
|
||||
|
||||
from ..exceptions import TdxDecodeError
|
||||
|
||||
|
||||
def get_price(data: bytes | bytearray, pos: int) -> tuple[int, int]:
|
||||
"""解码一个变长有符号整数。
|
||||
|
||||
Returns:
|
||||
(value, new_pos)
|
||||
"""
|
||||
bit_shift = 6
|
||||
start = pos
|
||||
try:
|
||||
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
|
||||
except IndexError as e:
|
||||
raise TdxDecodeError(f"price varint 截断: offset={start}") from e
|
||||
|
||||
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,94 @@
|
||||
"""A 股价格限制规则引擎。"""
|
||||
|
||||
from ..models.enums import Market
|
||||
from ..models.finance import FinanceInfo
|
||||
|
||||
|
||||
def get_no_limit_window_days(market: Market, code: str, name: str) -> int:
|
||||
"""返回上市初期不设涨跌幅限制的交易日窗口。
|
||||
|
||||
返回值:
|
||||
0: 默认按常规涨跌幅限制处理
|
||||
1: 北交所上市首日不设涨跌幅限制
|
||||
5: 沪深主板/创业板/科创板上市前 5 个交易日不设涨跌幅限制
|
||||
"""
|
||||
if _is_index_like(market, code, name):
|
||||
return 0
|
||||
|
||||
if code.startswith(("43", "83", "87", "92")):
|
||||
return 1
|
||||
|
||||
if market == Market.SH and code.startswith(("60", "68")):
|
||||
return 5
|
||||
if market == Market.SZ and code.startswith(("00", "30")):
|
||||
return 5
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _is_index_like(market: Market, code: str, name: str) -> bool:
|
||||
"""判断是否为指数/板块类代码。"""
|
||||
if market == Market.SH and code.startswith(
|
||||
("000", "880", "881", "882", "883", "884", "885", "999")
|
||||
):
|
||||
return True
|
||||
if market == Market.SZ and code.startswith(("395", "399")):
|
||||
return True
|
||||
return "指数" in name or "板块" in name
|
||||
|
||||
|
||||
def compute_price_limits(
|
||||
market: Market,
|
||||
code: str,
|
||||
name: str,
|
||||
pre_close: float,
|
||||
finance_info: FinanceInfo | None = None,
|
||||
listed_days: int | None = None,
|
||||
) -> tuple[float | None, float | None]:
|
||||
"""根据板块规则计算涨跌停价。
|
||||
|
||||
Returns:
|
||||
(limit_up, limit_down)
|
||||
|
||||
无涨跌幅限制或当前规则无法可靠判断时返回 ``(None, None)``。
|
||||
|
||||
Args:
|
||||
listed_days:
|
||||
已上市交易天数(按交易日计,首日=1)。
|
||||
若提供该值,函数会按上市初期无涨跌幅限制规则优先返回 ``(None, None)``。
|
||||
"""
|
||||
if pre_close <= 0:
|
||||
return None, None
|
||||
|
||||
upper_name = name.upper()
|
||||
|
||||
# 指数/板块类代码通常无涨跌停。
|
||||
if _is_index_like(market, code, name):
|
||||
return None, None
|
||||
|
||||
no_limit_window_days = get_no_limit_window_days(market, code, name)
|
||||
if listed_days is not None and 0 < listed_days <= no_limit_window_days:
|
||||
return None, None
|
||||
|
||||
limit_pct = 0.10 # 默认 10%
|
||||
|
||||
# 2. ST / *ST 判断
|
||||
if "ST" in upper_name:
|
||||
limit_pct = 0.05
|
||||
# 3. 科创板 (688) / 创业板 (300, 301)
|
||||
elif code.startswith("688") or code.startswith("300") or code.startswith("301"):
|
||||
limit_pct = 0.20
|
||||
# 4. 北交所 (43, 83, 87, 92)
|
||||
elif code.startswith(("43", "83", "87", "92")):
|
||||
limit_pct = 0.30
|
||||
|
||||
# `listed_days` 是更可靠的交易日维度输入;finance_info 仍保留给上层调用方扩展。
|
||||
_ = finance_info
|
||||
|
||||
def _round_price(p: float) -> float:
|
||||
return round(p + 0.00001, 2)
|
||||
|
||||
limit_up = _round_price(pre_close * (1 + limit_pct))
|
||||
limit_down = _round_price(pre_close * (1 - limit_pct))
|
||||
|
||||
return limit_up, limit_down
|
||||
@@ -0,0 +1,57 @@
|
||||
"""通达信 4 字节自定义浮点格式解码(成交量专用)。
|
||||
|
||||
格式:4 字节小端 uint32,分三段:
|
||||
[3] logpoint — 指数部分
|
||||
[2] hleax — 高精度部分
|
||||
[1] lheax — 中精度部分
|
||||
[0] lleax — 低精度部分
|
||||
|
||||
警告:此函数专为成交量设计,不可用于价格字段(pytdx Bug #3)。
|
||||
"""
|
||||
|
||||
|
||||
from .._binary import unpack_from
|
||||
|
||||
|
||||
def get_volume(data: bytes | bytearray, pos: int) -> tuple[float, int]:
|
||||
"""从 data[pos:pos+4] 解码成交量。
|
||||
|
||||
Returns:
|
||||
(volume_float, new_pos)
|
||||
"""
|
||||
(ivol,) = unpack_from("<I", data, pos, "volume")
|
||||
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,61 @@
|
||||
"""板块信息获取命令(元数据获取与分片下载)。
|
||||
|
||||
板块文件(如 block_zs.dat)包含行业、概念、风格等 A 股分类信息。
|
||||
"""
|
||||
|
||||
import struct
|
||||
|
||||
from ..exceptions import TdxDecodeError
|
||||
from .base import BaseCommand
|
||||
|
||||
|
||||
class GetBlockInfoMetaCmd(BaseCommand[tuple[int, str]]):
|
||||
"""获取板块文件的元数据(大小与 MD5 哈希)。
|
||||
|
||||
Args:
|
||||
filename: 板块文件名,如 'block_zs.dat', 'block_gn.dat' 等。
|
||||
"""
|
||||
|
||||
def __init__(self, filename: str) -> None:
|
||||
self.filename = filename.encode("ascii")
|
||||
|
||||
def build_request(self) -> bytes:
|
||||
# 固定头 12 字节
|
||||
header = bytes.fromhex("0c39186900012a002a00c502")
|
||||
# Payload 为文件名
|
||||
payload = (self.filename + b"\x00" * 40)[:40]
|
||||
return header + payload
|
||||
|
||||
def parse_response(self, body: bytes) -> tuple[int, str]:
|
||||
if len(body) < 38:
|
||||
raise TdxDecodeError(f"GetBlockInfoMeta 响应过短: {len(body)}")
|
||||
|
||||
size, _, hash_b, _ = struct.unpack("<I1s32s1s", body[:38])
|
||||
return size, hash_b.decode("ascii").strip("\x00")
|
||||
|
||||
|
||||
class GetBlockInfoCmd(BaseCommand[bytes]):
|
||||
"""分段获取板块文件二进制内容。
|
||||
|
||||
Args:
|
||||
filename: 板块文件名。
|
||||
start: 起始偏移量(字节)。
|
||||
length: 请求数据长度。
|
||||
"""
|
||||
|
||||
def __init__(self, filename: str, start: int, length: int) -> None:
|
||||
self.filename = filename.encode("ascii")
|
||||
self.start = start
|
||||
self.length = length
|
||||
|
||||
def build_request(self) -> bytes:
|
||||
# 固定头 12 字节
|
||||
header = bytes.fromhex("0c37186a00016e006e00b906")
|
||||
payload = struct.pack("<II", self.start, self.length)
|
||||
payload += (self.filename + b"\x00" * 100)[:100]
|
||||
return header + payload
|
||||
|
||||
def parse_response(self, body: bytes) -> bytes:
|
||||
if len(body) < 4:
|
||||
return b""
|
||||
return body[4:]
|
||||
@@ -0,0 +1,78 @@
|
||||
"""公司信息目录与内容命令。"""
|
||||
|
||||
import struct
|
||||
|
||||
from .._binary import slice_bytes, unpack_from
|
||||
from ..exceptions import TdxDecodeError
|
||||
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:
|
||||
raise TdxDecodeError("company_info_category body 过短")
|
||||
(num,) = unpack_from("<H", body, 0, "company_info_category header")
|
||||
pos = 2
|
||||
results: list[CompanyInfoCategory] = []
|
||||
|
||||
# 每条记录:64字节name + 80字节filename + 4字节start + 4字节length = 152字节
|
||||
_RECORD_SIZE = 152
|
||||
for _ in range(num):
|
||||
raw = slice_bytes(body, pos, _RECORD_SIZE, "company_info_category record")
|
||||
name_b, filename_b, start, length = struct.unpack("<64s80sII", raw)
|
||||
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(
|
||||
name=_decode(name_b),
|
||||
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:
|
||||
raise TdxDecodeError("company_info_content body 过短")
|
||||
_, length = unpack_from("<10sH", body, 0, "company_info_content header")
|
||||
content = slice_bytes(body, 12, length, "company_info_content body")
|
||||
return content.decode("gbk", errors="replace")
|
||||
@@ -0,0 +1,90 @@
|
||||
"""最新财务数据命令。"""
|
||||
|
||||
import struct
|
||||
|
||||
from .._binary import slice_bytes, unpack_from
|
||||
from ..exceptions import TdxDecodeError
|
||||
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 = unpack_from("<B6s", body, pos, "finance_info header")
|
||||
pos += 7
|
||||
|
||||
fields = struct.unpack(_FIN_FMT, slice_bytes(body, pos, _FIN_SIZE, "finance_info body"))
|
||||
(
|
||||
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 # 财务数据单位:万元/万股
|
||||
try:
|
||||
market = Market(market_b)
|
||||
except ValueError as e:
|
||||
raise TdxDecodeError(f"finance_info 非法 market 值: {market_b}") from e
|
||||
|
||||
return FinanceInfo(
|
||||
market=market,
|
||||
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[pos : pos + _FIN_SIZE],
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""历史资金流向命令 (Category 22)。"""
|
||||
|
||||
import struct
|
||||
|
||||
from ..codec.volume import _decode_volume
|
||||
from ..models.enums import Market
|
||||
from ..models.stats import HistoricalFundFlow
|
||||
from .base import BaseCommand
|
||||
|
||||
|
||||
class GetHistoryFundFlowCmd(BaseCommand[list[HistoricalFundFlow]]):
|
||||
"""获取历史日线资金流向序列。"""
|
||||
|
||||
def __init__(self, market: Market, code: str, start: int, count: int) -> None:
|
||||
self.market = market
|
||||
self.code = code.encode("utf-8")
|
||||
self.start = start
|
||||
self.count = count
|
||||
|
||||
def build_request(self) -> bytes:
|
||||
# Header (12 bytes) + Payload (28 bytes) = 40 bytes
|
||||
return struct.pack(
|
||||
"<HIHHHH6sHHHHIIH",
|
||||
0x010C,
|
||||
0x01016408,
|
||||
0x001C,
|
||||
0x001C,
|
||||
0x052D,
|
||||
int(self.market),
|
||||
self.code,
|
||||
22,
|
||||
1,
|
||||
self.start,
|
||||
self.count,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
def parse_response(self, body: bytes) -> list[HistoricalFundFlow]:
|
||||
# 响应格式:9字节头 + 2字节数量 + 每条记录 36 字节
|
||||
if len(body) < 11:
|
||||
return []
|
||||
|
||||
(num,) = struct.unpack("<H", body[9:11])
|
||||
pos = 11
|
||||
results = []
|
||||
|
||||
for _ in range(num):
|
||||
if len(body) < pos + 36:
|
||||
break
|
||||
|
||||
# 记录格式:4字节日期 + 8个4字节自定义浮点金额
|
||||
# [0]日期, [1..4]流入(超/大/中/小), [5..8]流出(超/大/中/小)
|
||||
raw_data = struct.unpack("<IIIIIIIII", body[pos:pos+36])
|
||||
|
||||
raw_date = raw_data[0]
|
||||
year = raw_date // 10000
|
||||
month = (raw_date // 100) % 100
|
||||
day = raw_date % 100
|
||||
|
||||
results.append(HistoricalFundFlow(
|
||||
year=year, month=month, day=day,
|
||||
super_in=_decode_volume(raw_data[1]),
|
||||
large_in=_decode_volume(raw_data[2]),
|
||||
medium_in=_decode_volume(raw_data[3]),
|
||||
small_in=_decode_volume(raw_data[4]),
|
||||
super_out=_decode_volume(raw_data[5]),
|
||||
large_out=_decode_volume(raw_data[6]),
|
||||
medium_out=_decode_volume(raw_data[7]),
|
||||
small_out=_decode_volume(raw_data[8]),
|
||||
))
|
||||
pos += 36
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,70 @@
|
||||
"""今日分时 / 历史分时数据命令。
|
||||
|
||||
unknown_1 字段:pytdx 中被完全丢弃,保留供分析(疑似均价)。
|
||||
"""
|
||||
|
||||
import struct
|
||||
|
||||
from .._binary import unpack_from
|
||||
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,) = unpack_from("<H", body, 0, "minute_time header")
|
||||
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,32 @@
|
||||
"""大文件拉取命令(用于 base_info.zip, gpcw.txt 等)。"""
|
||||
|
||||
import struct
|
||||
|
||||
from .base import BaseCommand
|
||||
|
||||
|
||||
class GetReportFileCmd(BaseCommand[bytes]):
|
||||
"""分段获取服务器上的报表或基础信息文件。
|
||||
|
||||
Args:
|
||||
filename: 远程文件名。
|
||||
start: 起始偏移量。
|
||||
length: 请求数据长度(建议 30000)。
|
||||
"""
|
||||
|
||||
def __init__(self, filename: str, start: int, length: int = 30000) -> None:
|
||||
self.filename = filename.encode("ascii")
|
||||
self.start = start
|
||||
self.length = length
|
||||
|
||||
def build_request(self) -> bytes:
|
||||
# 使用与 GetBlockInfo 相同的格式:0x06B9
|
||||
header = bytes.fromhex("0c37186a00016e006e00b906")
|
||||
payload = struct.pack("<II", self.start, self.length)
|
||||
payload += (self.filename + b"\x00" * 100)[:100]
|
||||
return header + payload
|
||||
|
||||
def parse_response(self, body: bytes) -> bytes:
|
||||
if len(body) < 4:
|
||||
return b""
|
||||
return body[4:]
|
||||
@@ -0,0 +1,157 @@
|
||||
"""获取 K 线数据命令(支持全部周期)。"""
|
||||
|
||||
import struct
|
||||
|
||||
from .._binary import unpack_from
|
||||
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:
|
||||
# Header (12 bytes) + Payload (28 bytes) = 40 bytes
|
||||
return struct.pack(
|
||||
"<HIHHHH6sHHHHIIH",
|
||||
0x010C,
|
||||
0x01016408,
|
||||
0x001C,
|
||||
0x001C,
|
||||
0x052D,
|
||||
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,) = unpack_from("<H", body, 0, "security_bars header")
|
||||
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 线相同,但响应每条记录在 vol+amt 后多 4 字节
|
||||
(上涨家数 uint16 + 下跌家数 uint16),必须跳过否则后续记录错位。
|
||||
"""
|
||||
|
||||
def parse_response(self, body: bytes) -> list[SecurityBar]:
|
||||
(ret_count,) = unpack_from("<H", body, 0, "security_bars header")
|
||||
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)
|
||||
|
||||
# 指数记录额外 4 字节:上涨家数 + 下跌家数(各 uint16 LE)
|
||||
pos += 4
|
||||
|
||||
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
|
||||
@@ -0,0 +1,25 @@
|
||||
"""获取市场股票/证券总数命令。"""
|
||||
|
||||
import struct
|
||||
|
||||
from .._binary import unpack_from
|
||||
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,) = unpack_from("<H", body, 0, "security_count")
|
||||
return int(count)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""获取证券列表命令(每页最多1000条,按 start 分页)。
|
||||
|
||||
修复 pytdx Bug #2:GBK 解码使用 errors='replace',截断多字节序列不再崩溃。
|
||||
修复 pytdx Bug #3:pre_close 保持使用通达信自定义浮点解码。
|
||||
"""
|
||||
|
||||
import struct
|
||||
|
||||
from .._binary import slice_bytes, unpack_from
|
||||
from ..codec.volume import _decode_volume
|
||||
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 (12 bytes) + Payload (6 bytes) = 18 bytes
|
||||
# Payload: Market(H), Start(H), Unknown(H)=0
|
||||
header = bytes.fromhex("0c0118640101060006005004".replace(" ", ""))
|
||||
return header + struct.pack("<HHH", int(self.market), self.start, 0)
|
||||
|
||||
def parse_response(self, body: bytes) -> list[SecurityInfo]:
|
||||
(num,) = unpack_from("<H", body, 0, "security_list header")
|
||||
pos = 2
|
||||
results: list[SecurityInfo] = []
|
||||
|
||||
for _ in range(num):
|
||||
raw = slice_bytes(body, pos, _RECORD_SIZE, "security_list record")
|
||||
(
|
||||
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")
|
||||
|
||||
# pre_close_raw 与协议里的成交量/股本字段一样,使用通达信自定义浮点编码。
|
||||
pre_close = _decode_volume(pre_close_raw)
|
||||
|
||||
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,203 @@
|
||||
"""获取实时五档行情命令(最多 80 只/次)。
|
||||
|
||||
所有未知字段(unknown_N)保留原始解析值,供逆向分析。
|
||||
"""
|
||||
|
||||
import struct
|
||||
|
||||
from .._binary import unpack_from
|
||||
from ..codec.price import get_price
|
||||
from ..codec.volume import get_volume
|
||||
from ..exceptions import TdxDecodeError
|
||||
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 字符串。
|
||||
|
||||
该字段编码为“小时 + 百万分之一小时的小数部分”。
|
||||
例如:14999212 → "14:59:57.163"
|
||||
"""
|
||||
hours, fractional_hour = divmod(raw, 1_000_000)
|
||||
total_millis = fractional_hour * 3600 // 1000
|
||||
minutes, remainder = divmod(total_millis, 60_000)
|
||||
seconds, millis = divmod(remainder, 1000)
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}"
|
||||
|
||||
|
||||
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,) = unpack_from("<H", body, pos, "security_quotes header")
|
||||
pos += 2
|
||||
|
||||
results: list[SecurityQuote] = []
|
||||
|
||||
for _ in range(num):
|
||||
record_start = pos
|
||||
|
||||
market_b, code_b, active1 = unpack_from(
|
||||
"<B6sH",
|
||||
body,
|
||||
pos,
|
||||
"security_quotes record header",
|
||||
)
|
||||
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, _ = 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,) = unpack_from("<H", body, pos, "security_quotes tail flag")
|
||||
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 = unpack_from(
|
||||
"<hH",
|
||||
body,
|
||||
pos,
|
||||
"security_quotes tail",
|
||||
)
|
||||
pos += 4
|
||||
|
||||
p = price_raw / 100.0
|
||||
try:
|
||||
market = Market(market_b)
|
||||
except ValueError as e:
|
||||
raise TdxDecodeError(f"security_quotes 非法 market 值: {market_b}") from e
|
||||
|
||||
results.append(
|
||||
SecurityQuote(
|
||||
market=market,
|
||||
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,
|
||||
limit_up=None,
|
||||
limit_down=None,
|
||||
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,105 @@
|
||||
"""逐笔成交命令(当日 + 历史)。
|
||||
|
||||
修复 pytdx Bug #4:保留原被 _ 丢弃的最后一个字段为 unknown_last。
|
||||
"""
|
||||
|
||||
import struct
|
||||
|
||||
from .._binary import unpack_from
|
||||
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)
|
||||
|
||||
|
||||
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,) = unpack_from("<H", body, 0, "transaction header")
|
||||
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,) = unpack_from("<H", body, 0, "history_transaction header")
|
||||
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,107 @@
|
||||
"""除权除息信息命令。
|
||||
|
||||
修复 pytdx Bug #1:循环内从正确的 pos 位置读取 market/code,
|
||||
不再始终读取 body[:7]。
|
||||
"""
|
||||
|
||||
import struct
|
||||
|
||||
from .._binary import slice_bytes, unpack_from
|
||||
from ..codec.datetime_ import get_datetime
|
||||
from ..codec.volume import _decode_volume
|
||||
from ..exceptions import TdxDecodeError
|
||||
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:
|
||||
raise TdxDecodeError("xdxr_info body 过短")
|
||||
|
||||
pos = 9 # 跳过9字节(market+code+未知)
|
||||
(num,) = unpack_from("<H", body, pos, "xdxr_info header")
|
||||
pos += 2
|
||||
|
||||
records: list[XdxrRecord] = []
|
||||
|
||||
for _ in range(num):
|
||||
record_start = pos
|
||||
|
||||
# Bug #1 修复:从当前 pos 读,而非 body[:7]
|
||||
market_b, code_b = unpack_from("<B6s", body, pos, "xdxr_info record header")
|
||||
pos += 7
|
||||
slice_bytes(body, pos, 1, "xdxr_info record padding")
|
||||
pos += 1 # 跳过1个未知字节
|
||||
|
||||
year, month, day, _hour, _min, pos = get_datetime(9, body, pos)
|
||||
(category,) = unpack_from("<B", body, pos, "xdxr_info category")
|
||||
pos += 1
|
||||
|
||||
chunk = slice_bytes(body, pos, 16, "xdxr_info record body")
|
||||
pos += 16
|
||||
try:
|
||||
market = Market(market_b)
|
||||
except ValueError as e:
|
||||
raise TdxDecodeError(f"xdxr_info 非法 market 值: {market_b}") from e
|
||||
|
||||
rec = XdxrRecord(
|
||||
market=market,
|
||||
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 = _normalize_per_10_shares(fenhong)
|
||||
rec.peigujia = peigujia
|
||||
rec.songzhuangu = _normalize_per_10_shares(songzhuangu)
|
||||
rec.peigu = _normalize_per_10_shares(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:
|
||||
"""股本数量解码(通达信自定义4字节浮点 → 万股)。
|
||||
|
||||
xdxr_info 的股本字段与成交量字段使用相同的自定义浮点编码,
|
||||
解码结果单位为万股,与 FinanceInfo.zong_guben / 10000 一致。
|
||||
"""
|
||||
return _decode_volume(raw)
|
||||
|
||||
|
||||
def _normalize_per_10_shares(value: float) -> float:
|
||||
"""将协议里的“每10股”口径归一化为“每股”口径。"""
|
||||
return value / 10.0
|
||||
@@ -0,0 +1,11 @@
|
||||
"""easy_tdx.ex — 通达信扩展行情(期货、港股、外股等,端口 7727)。"""
|
||||
|
||||
from .client import AsyncExTdxClient, ExTdxClient
|
||||
from .models import KNOWN_EX_HOSTS, KNOWN_EX_MARKETS
|
||||
|
||||
__all__ = [
|
||||
"ExTdxClient",
|
||||
"AsyncExTdxClient",
|
||||
"KNOWN_EX_HOSTS",
|
||||
"KNOWN_EX_MARKETS",
|
||||
]
|
||||
@@ -0,0 +1,439 @@
|
||||
"""扩展行情高层 API:ExTdxClient(同步)和 AsyncExTdxClient(asyncio)。"""
|
||||
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
from types import TracebackType
|
||||
from typing import TypeVar
|
||||
|
||||
from ..commands.base import BaseCommand
|
||||
from ..exceptions import TdxConnectionError
|
||||
from .commands.get_history_bars_range import GetExHistoryInstrumentBarsRangeCmd
|
||||
from .commands.get_instrument_bars import GetExInstrumentBarsCmd
|
||||
from .commands.get_instrument_count import GetExInstrumentCountCmd
|
||||
from .commands.get_instrument_info import GetExInstrumentInfoCmd
|
||||
from .commands.get_instrument_quote import GetExInstrumentQuoteCmd
|
||||
from .commands.get_instrument_quote_list import GetExInstrumentQuoteListCmd
|
||||
from .commands.get_markets import GetExMarketsCmd
|
||||
from .commands.get_minute_time import (
|
||||
GetExHistoryMinuteTimeDataCmd,
|
||||
GetExMinuteTimeDataCmd,
|
||||
)
|
||||
from .commands.get_transaction import (
|
||||
GetExHistoryTransactionDataCmd,
|
||||
GetExTransactionDataCmd,
|
||||
)
|
||||
from .models import (
|
||||
KNOWN_EX_HOSTS,
|
||||
ExInstrumentBar,
|
||||
ExInstrumentInfo,
|
||||
ExInstrumentQuote,
|
||||
ExMarketInfo,
|
||||
ExMinuteBar,
|
||||
ExTransactionRecord,
|
||||
)
|
||||
from .transport.async_ import AsyncExTdxConnection
|
||||
from .transport.sync import ExTdxConnection, ping_ex_all
|
||||
|
||||
_DEFAULT_EX_PORT = 7727
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 同步客户端
|
||||
# ============================================================
|
||||
|
||||
|
||||
class ExTdxClient:
|
||||
"""同步扩展行情客户端(期货、港股、外股等,端口 7727)。
|
||||
|
||||
使用示例::
|
||||
|
||||
with ExTdxClient("61.152.107.141") as c:
|
||||
markets = c.get_markets()
|
||||
quote = c.get_instrument_quote(47, "IFL0")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = KNOWN_EX_HOSTS[0],
|
||||
port: int = _DEFAULT_EX_PORT,
|
||||
timeout: float = 15.0,
|
||||
auto_reconnect: bool = True,
|
||||
) -> None:
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._timeout = timeout
|
||||
self._auto_reconnect = auto_reconnect
|
||||
self._conn = ExTdxConnection(host, port, timeout)
|
||||
|
||||
@classmethod
|
||||
def from_best_host(
|
||||
cls,
|
||||
hosts: list[str] | None = None,
|
||||
port: int = _DEFAULT_EX_PORT,
|
||||
timeout: float = 15.0,
|
||||
ping_timeout: float = 5.0,
|
||||
auto_reconnect: bool = True,
|
||||
) -> "ExTdxClient":
|
||||
"""测量所有扩展行情服务器延迟,选最低延迟建立连接。"""
|
||||
ranked = ping_ex_all(hosts, port, ping_timeout)
|
||||
best = ranked[0][0] if ranked else (hosts or KNOWN_EX_HOSTS)[0]
|
||||
return cls(best, port, timeout, auto_reconnect)
|
||||
|
||||
@staticmethod
|
||||
def ping_all(
|
||||
hosts: list[str] | None = None,
|
||||
port: int = _DEFAULT_EX_PORT,
|
||||
timeout: float = 5.0,
|
||||
) -> list[tuple[str, float]]:
|
||||
return ping_ex_all(hosts, port, timeout)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 连接管理
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def connect(self) -> None:
|
||||
self._conn.connect()
|
||||
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
def __enter__(self) -> "ExTdxClient":
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: TracebackType | None,
|
||||
) -> None:
|
||||
self.close()
|
||||
|
||||
def _execute(self, cmd: "BaseCommand[_T]") -> _T:
|
||||
try:
|
||||
return self._conn.execute(cmd)
|
||||
except TdxConnectionError:
|
||||
if not self._auto_reconnect:
|
||||
raise
|
||||
self._conn.close()
|
||||
self._conn = ExTdxConnection(self._host, self._port, self._timeout)
|
||||
self._conn.connect()
|
||||
return self._conn.execute(cmd)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 市场信息
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def get_markets(self) -> list[ExMarketInfo]:
|
||||
"""获取扩展行情支持的市场列表。"""
|
||||
return self._execute(GetExMarketsCmd())
|
||||
|
||||
def get_instrument_count(self) -> int:
|
||||
"""获取扩展行情商品总数。"""
|
||||
return self._execute(GetExInstrumentCountCmd())
|
||||
|
||||
def get_instrument_info(self, start: int, count: int = 100) -> list[ExInstrumentInfo]:
|
||||
"""获取商品信息列表(分页)。"""
|
||||
return self._execute(GetExInstrumentInfoCmd(start, count))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 行情
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def get_instrument_quote(self, market: int, code: str) -> ExInstrumentQuote | None:
|
||||
"""获取单个商品五档实时行情。"""
|
||||
return self._execute(GetExInstrumentQuoteCmd(market, code))
|
||||
|
||||
def get_instrument_quote_list(
|
||||
self,
|
||||
market: int,
|
||||
category: int,
|
||||
start: int = 0,
|
||||
count: int = 80,
|
||||
) -> list[OrderedDict[str, object]]:
|
||||
"""按类别获取商品行情列表。"""
|
||||
return self._execute(GetExInstrumentQuoteListCmd(market, category, start, count))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# K线
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def get_instrument_bars(
|
||||
self,
|
||||
category: int,
|
||||
market: int,
|
||||
code: str,
|
||||
start: int = 0,
|
||||
count: int = 700,
|
||||
) -> list[ExInstrumentBar]:
|
||||
"""获取K线数据。"""
|
||||
return self._execute(GetExInstrumentBarsCmd(category, market, code, start, count))
|
||||
|
||||
def get_history_instrument_bars_range(
|
||||
self,
|
||||
market: int,
|
||||
code: str,
|
||||
start_date: int,
|
||||
end_date: int,
|
||||
) -> list[ExInstrumentBar]:
|
||||
"""按日期范围获取历史K线。"""
|
||||
return self._execute(GetExHistoryInstrumentBarsRangeCmd(market, code, start_date, end_date))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 分时
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def get_minute_time_data(self, market: int, code: str) -> list[ExMinuteBar]:
|
||||
"""获取当日分时行情数据。"""
|
||||
return self._execute(GetExMinuteTimeDataCmd(market, code))
|
||||
|
||||
def get_history_minute_time_data(
|
||||
self,
|
||||
market: int,
|
||||
code: str,
|
||||
date: int,
|
||||
) -> list[ExMinuteBar]:
|
||||
"""获取历史某日分时行情数据(date: YYYYMMDD)。"""
|
||||
return self._execute(GetExHistoryMinuteTimeDataCmd(market, code, date))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 成交
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def get_transaction_data(
|
||||
self,
|
||||
market: int,
|
||||
code: str,
|
||||
start: int = 0,
|
||||
count: int = 1800,
|
||||
) -> list[ExTransactionRecord]:
|
||||
"""获取当日分笔成交数据。"""
|
||||
return self._execute(GetExTransactionDataCmd(market, code, start, count))
|
||||
|
||||
def get_history_transaction_data(
|
||||
self,
|
||||
market: int,
|
||||
code: str,
|
||||
date: int,
|
||||
start: int = 0,
|
||||
count: int = 1800,
|
||||
) -> list[ExTransactionRecord]:
|
||||
"""获取历史某日分笔成交数据(date: YYYYMMDD)。"""
|
||||
return self._execute(GetExHistoryTransactionDataCmd(market, code, date, start, count))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 异步客户端
|
||||
# ============================================================
|
||||
|
||||
|
||||
class AsyncExTdxClient:
|
||||
"""异步扩展行情客户端(asyncio,端口 7727)。
|
||||
|
||||
使用示例::
|
||||
|
||||
async with AsyncExTdxClient("61.152.107.141") as c:
|
||||
markets = await c.get_markets()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = KNOWN_EX_HOSTS[0],
|
||||
port: int = _DEFAULT_EX_PORT,
|
||||
timeout: float = 15.0,
|
||||
auto_reconnect: bool = True,
|
||||
heartbeat_interval: float = 60.0,
|
||||
) -> None:
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._timeout = timeout
|
||||
self._auto_reconnect = auto_reconnect
|
||||
self._heartbeat_interval = heartbeat_interval
|
||||
self._conn = AsyncExTdxConnection(host, port, timeout)
|
||||
self._execute_lock = asyncio.Lock()
|
||||
self._heartbeat_task: asyncio.Task[None] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_best_host(
|
||||
cls,
|
||||
hosts: list[str] | None = None,
|
||||
port: int = _DEFAULT_EX_PORT,
|
||||
timeout: float = 15.0,
|
||||
ping_timeout: float = 5.0,
|
||||
auto_reconnect: bool = True,
|
||||
heartbeat_interval: float = 60.0,
|
||||
) -> "AsyncExTdxClient":
|
||||
ranked = ping_ex_all(hosts, port, ping_timeout)
|
||||
best = ranked[0][0] if ranked else (hosts or KNOWN_EX_HOSTS)[0]
|
||||
return cls(best, port, timeout, auto_reconnect, heartbeat_interval)
|
||||
|
||||
@staticmethod
|
||||
def ping_all(
|
||||
hosts: list[str] | None = None,
|
||||
port: int = _DEFAULT_EX_PORT,
|
||||
timeout: float = 5.0,
|
||||
) -> list[tuple[str, float]]:
|
||||
return ping_ex_all(hosts, port, timeout)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 连接管理
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def connect(self) -> None:
|
||||
await self._conn.connect()
|
||||
self._start_heartbeat()
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._stop_heartbeat()
|
||||
await self._conn.close()
|
||||
|
||||
async def __aenter__(self) -> "AsyncExTdxClient":
|
||||
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()
|
||||
|
||||
def _start_heartbeat(self) -> None:
|
||||
if self._heartbeat_interval <= 0:
|
||||
return
|
||||
if self._heartbeat_task is not None:
|
||||
self._heartbeat_task.cancel()
|
||||
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||
|
||||
async def _stop_heartbeat(self) -> None:
|
||||
if self._heartbeat_task:
|
||||
self._heartbeat_task.cancel()
|
||||
try:
|
||||
await self._heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._heartbeat_task = None
|
||||
|
||||
async def _heartbeat_loop(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(self._heartbeat_interval)
|
||||
await self.get_instrument_count()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _execute(self, cmd: "BaseCommand[_T]") -> _T:
|
||||
async with self._execute_lock:
|
||||
try:
|
||||
return await self._conn.execute(cmd)
|
||||
except TdxConnectionError:
|
||||
if not self._auto_reconnect:
|
||||
raise
|
||||
await self._conn.close()
|
||||
self._conn = AsyncExTdxConnection(self._host, self._port, self._timeout)
|
||||
await self._conn.connect()
|
||||
return await self._conn.execute(cmd)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 市场信息
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def get_markets(self) -> list[ExMarketInfo]:
|
||||
return await self._execute(GetExMarketsCmd())
|
||||
|
||||
async def get_instrument_count(self) -> int:
|
||||
return await self._execute(GetExInstrumentCountCmd())
|
||||
|
||||
async def get_instrument_info(
|
||||
self,
|
||||
start: int,
|
||||
count: int = 100,
|
||||
) -> list[ExInstrumentInfo]:
|
||||
return await self._execute(GetExInstrumentInfoCmd(start, count))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 行情
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def get_instrument_quote(
|
||||
self,
|
||||
market: int,
|
||||
code: str,
|
||||
) -> ExInstrumentQuote | None:
|
||||
return await self._execute(GetExInstrumentQuoteCmd(market, code))
|
||||
|
||||
async def get_instrument_quote_list(
|
||||
self,
|
||||
market: int,
|
||||
category: int,
|
||||
start: int = 0,
|
||||
count: int = 80,
|
||||
) -> list[OrderedDict[str, object]]:
|
||||
return await self._execute(GetExInstrumentQuoteListCmd(market, category, start, count))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# K线
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def get_instrument_bars(
|
||||
self,
|
||||
category: int,
|
||||
market: int,
|
||||
code: str,
|
||||
start: int = 0,
|
||||
count: int = 700,
|
||||
) -> list[ExInstrumentBar]:
|
||||
return await self._execute(GetExInstrumentBarsCmd(category, market, code, start, count))
|
||||
|
||||
async def get_history_instrument_bars_range(
|
||||
self,
|
||||
market: int,
|
||||
code: str,
|
||||
start_date: int,
|
||||
end_date: int,
|
||||
) -> list[ExInstrumentBar]:
|
||||
return await self._execute(
|
||||
GetExHistoryInstrumentBarsRangeCmd(market, code, start_date, end_date)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 分时
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def get_minute_time_data(self, market: int, code: str) -> list[ExMinuteBar]:
|
||||
return await self._execute(GetExMinuteTimeDataCmd(market, code))
|
||||
|
||||
async def get_history_minute_time_data(
|
||||
self,
|
||||
market: int,
|
||||
code: str,
|
||||
date: int,
|
||||
) -> list[ExMinuteBar]:
|
||||
return await self._execute(GetExHistoryMinuteTimeDataCmd(market, code, date))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 成交
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def get_transaction_data(
|
||||
self,
|
||||
market: int,
|
||||
code: str,
|
||||
start: int = 0,
|
||||
count: int = 1800,
|
||||
) -> list[ExTransactionRecord]:
|
||||
return await self._execute(GetExTransactionDataCmd(market, code, start, count))
|
||||
|
||||
async def get_history_transaction_data(
|
||||
self,
|
||||
market: int,
|
||||
code: str,
|
||||
date: int,
|
||||
start: int = 0,
|
||||
count: int = 1800,
|
||||
) -> list[ExTransactionRecord]:
|
||||
return await self._execute(GetExHistoryTransactionDataCmd(market, code, date, start, count))
|
||||
@@ -0,0 +1 @@
|
||||
"""扩展行情命令模块。"""
|
||||
@@ -0,0 +1,76 @@
|
||||
"""获取扩展行情历史K线(按日期范围)。"""
|
||||
|
||||
import struct
|
||||
|
||||
from ...commands.base import BaseCommand
|
||||
from ..models import ExInstrumentBar
|
||||
|
||||
|
||||
class GetExHistoryInstrumentBarsRangeCmd(BaseCommand[list[ExInstrumentBar]]):
|
||||
"""按日期范围获取历史K线数据。"""
|
||||
|
||||
_seqid: int = 1
|
||||
|
||||
def __init__(self, market: int, code: str, start_date: int, end_date: int) -> None:
|
||||
self.market = market
|
||||
self.code = code.encode("utf-8")
|
||||
self.start_date = start_date
|
||||
self.end_date = end_date
|
||||
|
||||
def build_request(self) -> bytes:
|
||||
pkg = bytearray.fromhex("01")
|
||||
pkg.extend(struct.pack("<B", self._seqid))
|
||||
self.__class__._seqid += 1
|
||||
pkg.extend(bytearray.fromhex("38 92 00 01 16 00 16 00 0D 24"))
|
||||
pkg.extend(struct.pack("<B9s", self.market, self.code))
|
||||
pkg.extend(bytearray.fromhex("07 00"))
|
||||
pkg.extend(struct.pack("<II", self.start_date, self.end_date))
|
||||
return bytes(pkg)
|
||||
|
||||
@staticmethod
|
||||
def _parse_date(num: int) -> tuple[int, int, int]:
|
||||
year = num // 2048 + 2004
|
||||
month = (num % 2048) // 100
|
||||
day = (num % 2048) % 100
|
||||
return year, month, day
|
||||
|
||||
@staticmethod
|
||||
def _parse_time(num: int) -> tuple[int, int]:
|
||||
return num // 60, num % 60
|
||||
|
||||
def parse_response(self, body: bytes) -> list[ExInstrumentBar]:
|
||||
pos = 12 # skip 12-byte header
|
||||
if pos + 2 > len(body):
|
||||
return []
|
||||
(ret_count,) = struct.unpack("<H", body[pos : pos + 2])
|
||||
pos += 2
|
||||
results: list[ExInstrumentBar] = []
|
||||
for _ in range(ret_count):
|
||||
if pos + 32 > len(body):
|
||||
break
|
||||
record_start = pos
|
||||
(d1, d2, open_p, high, low, close_p, position, trade, settlement) = struct.unpack(
|
||||
"<HHffffIIf",
|
||||
body[pos : pos + 32],
|
||||
)
|
||||
pos += 32
|
||||
year, month, day = self._parse_date(d1)
|
||||
hour, minute = self._parse_time(d2)
|
||||
results.append(
|
||||
ExInstrumentBar(
|
||||
open=open_p,
|
||||
high=high,
|
||||
low=low,
|
||||
close=close_p,
|
||||
position=position,
|
||||
trade=trade,
|
||||
amount=settlement,
|
||||
year=year,
|
||||
month=month,
|
||||
day=day,
|
||||
hour=hour,
|
||||
minute=minute,
|
||||
_raw=body[record_start:pos],
|
||||
)
|
||||
)
|
||||
return results
|
||||
@@ -0,0 +1,74 @@
|
||||
"""获取扩展行情K线数据。"""
|
||||
|
||||
import struct
|
||||
|
||||
from ...codec.datetime_ import get_datetime
|
||||
from ...commands.base import BaseCommand
|
||||
from ..models import ExInstrumentBar
|
||||
|
||||
|
||||
class GetExInstrumentBarsCmd(BaseCommand[list[ExInstrumentBar]]):
|
||||
"""获取K线数据(扩展行情版本,支持期货/港股等)。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
category: int,
|
||||
market: int,
|
||||
code: str,
|
||||
start: int = 0,
|
||||
count: int = 700,
|
||||
) -> None:
|
||||
self.category = category
|
||||
self.market = market
|
||||
self.code = code.encode("utf-8")
|
||||
self.start = start
|
||||
self.count = count
|
||||
|
||||
def build_request(self) -> bytes:
|
||||
header = bytes.fromhex("01 01 08 6a 01 01 16 00 16 00 ff 23")
|
||||
return header + struct.pack(
|
||||
"<B9sHHIH",
|
||||
self.market,
|
||||
self.code,
|
||||
self.category,
|
||||
1,
|
||||
self.start,
|
||||
self.count,
|
||||
)
|
||||
|
||||
def parse_response(self, body: bytes) -> list[ExInstrumentBar]:
|
||||
pos = 18 # skip 18-byte header
|
||||
if pos + 2 > len(body):
|
||||
return []
|
||||
(ret_count,) = struct.unpack("<H", body[pos : pos + 2])
|
||||
pos += 2
|
||||
results: list[ExInstrumentBar] = []
|
||||
for _ in range(ret_count):
|
||||
record_start = pos
|
||||
year, month, day, hour, minute, pos = get_datetime(self.category, body, pos)
|
||||
if pos + 28 > len(body):
|
||||
break
|
||||
(open_p, high, low, close_p, position, trade, _price) = struct.unpack(
|
||||
"<ffffIIf",
|
||||
body[pos : pos + 28],
|
||||
)
|
||||
(amount,) = struct.unpack("<f", body[pos + 16 : pos + 20])
|
||||
pos += 28
|
||||
results.append(
|
||||
ExInstrumentBar(
|
||||
open=open_p,
|
||||
high=high,
|
||||
low=low,
|
||||
close=close_p,
|
||||
position=position,
|
||||
trade=trade,
|
||||
amount=amount,
|
||||
year=year,
|
||||
month=month,
|
||||
day=day,
|
||||
hour=hour,
|
||||
minute=minute,
|
||||
_raw=body[record_start:pos],
|
||||
)
|
||||
)
|
||||
return results
|
||||
@@ -0,0 +1,17 @@
|
||||
"""获取扩展行情商品数量。"""
|
||||
|
||||
from ..._binary import unpack_from
|
||||
from ...commands.base import BaseCommand
|
||||
|
||||
|
||||
class GetExInstrumentCountCmd(BaseCommand[int]):
|
||||
"""获取扩展行情市场中商品总数。"""
|
||||
|
||||
def build_request(self) -> bytes:
|
||||
return bytes.fromhex("01 03 48 66 00 01 02 00 02 00 f0 23")
|
||||
|
||||
def parse_response(self, body: bytes) -> int:
|
||||
if len(body) < 23:
|
||||
return 0
|
||||
(count,) = unpack_from("<I", body, 19, "ex instrument count")
|
||||
return count
|
||||
@@ -0,0 +1,50 @@
|
||||
"""获取扩展行情商品信息。"""
|
||||
|
||||
import struct
|
||||
|
||||
from ...commands.base import BaseCommand
|
||||
from ..models import ExInstrumentInfo
|
||||
|
||||
|
||||
class GetExInstrumentInfoCmd(BaseCommand[list[ExInstrumentInfo]]):
|
||||
"""获取扩展行情市场中的商品信息列表。"""
|
||||
|
||||
def __init__(self, start: int, count: int = 100) -> None:
|
||||
self.start = start
|
||||
self.count = count
|
||||
|
||||
def build_request(self) -> bytes:
|
||||
header = bytes.fromhex("01 04 48 67 00 01 08 00 08 00 f5 23")
|
||||
return header + struct.pack("<IH", self.start, self.count)
|
||||
|
||||
def parse_response(self, body: bytes) -> list[ExInstrumentInfo]:
|
||||
if len(body) < 6:
|
||||
return []
|
||||
pos = 0
|
||||
(_start, _count) = struct.unpack("<IH", body[pos : pos + 6])
|
||||
count = _count
|
||||
pos += 6
|
||||
results: list[ExInstrumentInfo] = []
|
||||
for _ in range(count):
|
||||
if pos + 64 > len(body):
|
||||
break
|
||||
raw = body[pos : pos + 64]
|
||||
(category, market, _unused, raw_code, raw_name, raw_desc) = struct.unpack(
|
||||
"<BB3s9s17s9s",
|
||||
raw[:40],
|
||||
)
|
||||
pos += 64
|
||||
code = raw_code.decode("gbk", errors="replace").rstrip("\x00")
|
||||
name = raw_name.decode("gbk", errors="replace").rstrip("\x00")
|
||||
desc = raw_desc.decode("gbk", errors="replace").rstrip("\x00")
|
||||
results.append(
|
||||
ExInstrumentInfo(
|
||||
category=category,
|
||||
market=market,
|
||||
code=code,
|
||||
name=name,
|
||||
desc=desc,
|
||||
_raw=raw,
|
||||
)
|
||||
)
|
||||
return results
|
||||
@@ -0,0 +1,103 @@
|
||||
"""获取扩展行情实时五档报价。"""
|
||||
|
||||
import struct
|
||||
|
||||
from ...commands.base import BaseCommand
|
||||
from ..models import ExInstrumentQuote
|
||||
|
||||
|
||||
class GetExInstrumentQuoteCmd(BaseCommand[ExInstrumentQuote | None]):
|
||||
"""获取单个商品的五档实时行情。"""
|
||||
|
||||
def __init__(self, market: int, code: str) -> None:
|
||||
self.market = market
|
||||
self.code = code.encode("utf-8")
|
||||
|
||||
def build_request(self) -> bytes:
|
||||
header = bytes.fromhex("01 01 08 02 02 01 0c 00 0c 00 fa 23")
|
||||
return header + struct.pack("<B9s", self.market, self.code)
|
||||
|
||||
def parse_response(self, body: bytes) -> ExInstrumentQuote | None:
|
||||
if len(body) < 150:
|
||||
return None
|
||||
pos = 0
|
||||
(market, raw_code) = struct.unpack("<B9s", body[pos : pos + 10])
|
||||
pos += 10
|
||||
pos += 4 # skip 4 unknown bytes
|
||||
record_start = pos - 14
|
||||
(
|
||||
pre_close,
|
||||
open_price,
|
||||
high,
|
||||
low,
|
||||
price,
|
||||
kaicang,
|
||||
_unk1,
|
||||
zongliang,
|
||||
xianliang,
|
||||
_unk2,
|
||||
neipan,
|
||||
waipan,
|
||||
_unk3,
|
||||
chicang,
|
||||
b1,
|
||||
b2,
|
||||
b3,
|
||||
b4,
|
||||
b5,
|
||||
bv1,
|
||||
bv2,
|
||||
bv3,
|
||||
bv4,
|
||||
bv5,
|
||||
a1,
|
||||
a2,
|
||||
a3,
|
||||
a4,
|
||||
a5,
|
||||
av1,
|
||||
av2,
|
||||
av3,
|
||||
av4,
|
||||
av5,
|
||||
) = struct.unpack(
|
||||
"<fffffIIIIIIIIIfffffIIIIIfffffIIIII",
|
||||
body[pos : pos + 136],
|
||||
)
|
||||
code = raw_code.decode("utf-8", errors="replace").rstrip("\x00")
|
||||
return ExInstrumentQuote(
|
||||
market=market,
|
||||
code=code,
|
||||
pre_close=pre_close,
|
||||
open=open_price,
|
||||
high=high,
|
||||
low=low,
|
||||
price=price,
|
||||
kaicang=kaicang,
|
||||
zongliang=zongliang,
|
||||
xianliang=xianliang,
|
||||
neipan=neipan,
|
||||
waipan=waipan,
|
||||
chicang=chicang,
|
||||
bid1=b1,
|
||||
bid2=b2,
|
||||
bid3=b3,
|
||||
bid4=b4,
|
||||
bid5=b5,
|
||||
bid_vol1=bv1,
|
||||
bid_vol2=bv2,
|
||||
bid_vol3=bv3,
|
||||
bid_vol4=bv4,
|
||||
bid_vol5=bv5,
|
||||
ask1=a1,
|
||||
ask2=a2,
|
||||
ask3=a3,
|
||||
ask4=a4,
|
||||
ask5=a5,
|
||||
ask_vol1=av1,
|
||||
ask_vol2=av2,
|
||||
ask_vol3=av3,
|
||||
ask_vol4=av4,
|
||||
ask_vol5=av5,
|
||||
_raw=body[record_start : pos + 136],
|
||||
)
|
||||
@@ -0,0 +1,218 @@
|
||||
"""获取扩展行情商品列表行情。"""
|
||||
|
||||
import struct
|
||||
from collections import OrderedDict
|
||||
|
||||
from ...commands.base import BaseCommand
|
||||
from ...exceptions import TdxCommandError
|
||||
|
||||
|
||||
class GetExInstrumentQuoteListCmd(BaseCommand[list[OrderedDict[str, object]]]):
|
||||
"""按类别获取商品行情列表(期货/港股等)。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
market: int,
|
||||
category: int,
|
||||
start: int = 0,
|
||||
count: int = 80,
|
||||
) -> None:
|
||||
self.market = market
|
||||
self.category = category
|
||||
self.start = start
|
||||
self.count = count
|
||||
|
||||
def build_request(self) -> bytes:
|
||||
header = bytes.fromhex("01 c1 06 0b 00 02 0b 00 0b 00 00 24")
|
||||
return header + struct.pack(
|
||||
"<BHHHH",
|
||||
self.market,
|
||||
0,
|
||||
self.start,
|
||||
self.count,
|
||||
1,
|
||||
)
|
||||
|
||||
def parse_response(self, body: bytes) -> list[OrderedDict[str, object]]:
|
||||
if len(body) < 2:
|
||||
return []
|
||||
(num,) = struct.unpack("<H", body[0:2])
|
||||
pos = 2
|
||||
results: list[OrderedDict[str, object]] = []
|
||||
for _ in range(num):
|
||||
if pos + 10 > len(body):
|
||||
break
|
||||
(market, raw_code) = struct.unpack("<B9s", body[pos : pos + 10])
|
||||
code = raw_code.strip(b"\x00").decode("gbk", errors="replace")
|
||||
pos += 10
|
||||
if self.category == 3:
|
||||
pos = self._parse_futures(market, code, body, pos, results)
|
||||
elif self.category == 2:
|
||||
pos = self._parse_hk_stocks(market, code, body, pos, results)
|
||||
else:
|
||||
raise TdxCommandError(f"不支持的扩展行情类别: {self.category}")
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _parse_futures(
|
||||
market: int,
|
||||
code: str,
|
||||
body: bytes,
|
||||
pos: int,
|
||||
results: list[OrderedDict[str, object]],
|
||||
) -> int:
|
||||
if pos + 140 > len(body):
|
||||
return pos + 290
|
||||
(
|
||||
bi_shu,
|
||||
zuo_jie,
|
||||
jin_kai,
|
||||
zui_gao,
|
||||
zui_di,
|
||||
mai_chu,
|
||||
kai_cang,
|
||||
_unk1,
|
||||
zong_liang,
|
||||
xian_liang,
|
||||
zong_jin_e,
|
||||
nei_pan,
|
||||
wai_pan,
|
||||
_unk2,
|
||||
chi_cang_liang,
|
||||
mai_ru_jia,
|
||||
_u1,
|
||||
_u2,
|
||||
_u3,
|
||||
_u4,
|
||||
mai_ru_liang,
|
||||
_u5,
|
||||
_u6,
|
||||
_u7,
|
||||
_u8,
|
||||
mai_chu_jia,
|
||||
_u9,
|
||||
_u10,
|
||||
_u11,
|
||||
_u12,
|
||||
mai_chu_liang,
|
||||
_u13,
|
||||
_u14,
|
||||
_u15,
|
||||
) = struct.unpack("<IfffffIIIIfIIfIfIIIIIIIIIfIIIIIIIII", body[pos : pos + 140])
|
||||
pos += 290
|
||||
results.append(
|
||||
OrderedDict(
|
||||
[
|
||||
("market", market),
|
||||
("code", code),
|
||||
("BiShu", bi_shu),
|
||||
("ZuoJie", zuo_jie),
|
||||
("JinKai", jin_kai),
|
||||
("ZuiGao", zui_gao),
|
||||
("ZuiDi", zui_di),
|
||||
("MaiChu", mai_chu),
|
||||
("KaiCang", kai_cang),
|
||||
("ZongLiang", zong_liang),
|
||||
("XianLiang", xian_liang),
|
||||
("ZongJinE", zong_jin_e),
|
||||
("NeiPan", nei_pan),
|
||||
("WaiPan", wai_pan),
|
||||
("ChiCangLiang", chi_cang_liang),
|
||||
("MaiRuJia", mai_ru_jia),
|
||||
("MaiRuLiang", mai_ru_liang),
|
||||
("MaiChuJia", mai_chu_jia),
|
||||
("MaiChuLiang", mai_chu_liang),
|
||||
]
|
||||
)
|
||||
)
|
||||
return pos
|
||||
|
||||
@staticmethod
|
||||
def _parse_hk_stocks(
|
||||
market: int,
|
||||
code: str,
|
||||
body: bytes,
|
||||
pos: int,
|
||||
results: list[OrderedDict[str, object]],
|
||||
) -> int:
|
||||
if pos + 140 > len(body):
|
||||
return pos + 290
|
||||
(
|
||||
huo_yue_du,
|
||||
zuo_shou,
|
||||
jin_kai,
|
||||
zui_gao,
|
||||
zui_di,
|
||||
xian_jia,
|
||||
_unk1,
|
||||
mai_ru_jia,
|
||||
zong_liang,
|
||||
xian_liang,
|
||||
zong_jin_e,
|
||||
_unk2,
|
||||
_unk3,
|
||||
nei,
|
||||
wai,
|
||||
mrj1,
|
||||
mrj2,
|
||||
mrj3,
|
||||
mrj4,
|
||||
mrj5,
|
||||
mrl1,
|
||||
mrl2,
|
||||
mrl3,
|
||||
mrl4,
|
||||
mrl5,
|
||||
mcj1,
|
||||
mcj2,
|
||||
mcj3,
|
||||
mcj4,
|
||||
mcj5,
|
||||
mcl1,
|
||||
mcl2,
|
||||
mcl3,
|
||||
mcl4,
|
||||
mcl5,
|
||||
) = struct.unpack("<IfffffIfIIfIIIIfffffIIIIIfffffIIIII", body[pos : pos + 140])
|
||||
pos += 290
|
||||
results.append(
|
||||
OrderedDict(
|
||||
[
|
||||
("market", market),
|
||||
("code", code),
|
||||
("HuoYueDu", huo_yue_du),
|
||||
("ZuoShou", zuo_shou),
|
||||
("JinKai", jin_kai),
|
||||
("ZuiGao", zui_gao),
|
||||
("ZuiDi", zui_di),
|
||||
("XianJia", xian_jia),
|
||||
("MaiRuJia", mai_ru_jia),
|
||||
("ZongLiang", zong_liang),
|
||||
("XianLiang", xian_liang),
|
||||
("ZongJinE", zong_jin_e),
|
||||
("Nei", nei),
|
||||
("Wai", wai),
|
||||
("MaiRuJia1", mrj1),
|
||||
("MaiRuJia2", mrj2),
|
||||
("MaiRuJia3", mrj3),
|
||||
("MaiRuJia4", mrj4),
|
||||
("MaiRuJia5", mrj5),
|
||||
("MaiRuLiang1", mrl1),
|
||||
("MaiRuLiang2", mrl2),
|
||||
("MaiRuLiang3", mrl3),
|
||||
("MaiRuLiang4", mrl4),
|
||||
("MaiRuLiang5", mrl5),
|
||||
("MaiChuJia1", mcj1),
|
||||
("MaiChuJia2", mcj2),
|
||||
("MaiChuJia3", mcj3),
|
||||
("MaiChuJia4", mcj4),
|
||||
("MaiChuJia5", mcj5),
|
||||
("MaiChuLiang1", mcl1),
|
||||
("MaiChuLiang2", mcl2),
|
||||
("MaiChuLiang3", mcl3),
|
||||
("MaiChuLiang4", mcl4),
|
||||
("MaiChuLiang5", mcl5),
|
||||
]
|
||||
)
|
||||
)
|
||||
return pos
|
||||
@@ -0,0 +1,41 @@
|
||||
"""获取扩展行情市场列表。"""
|
||||
|
||||
import struct
|
||||
|
||||
from ..._binary import unpack_from
|
||||
from ...commands.base import BaseCommand
|
||||
from ..models import ExMarketInfo
|
||||
|
||||
|
||||
class GetExMarketsCmd(BaseCommand[list[ExMarketInfo]]):
|
||||
"""获取扩展行情支持的市场列表。"""
|
||||
|
||||
def build_request(self) -> bytes:
|
||||
return bytes.fromhex("01 02 48 69 00 01 02 00 02 00 f4 23")
|
||||
|
||||
def parse_response(self, body: bytes) -> list[ExMarketInfo]:
|
||||
if len(body) < 2:
|
||||
return []
|
||||
(count,) = unpack_from("<H", body, 0, "ex markets count")
|
||||
pos = 2
|
||||
results: list[ExMarketInfo] = []
|
||||
for _ in range(count):
|
||||
if pos + 64 > len(body):
|
||||
break
|
||||
raw = body[pos : pos + 64]
|
||||
(category, raw_name, market, raw_short_name) = struct.unpack("<B32sB2s", raw[:36])
|
||||
pos += 64
|
||||
if category == 0 and market == 0:
|
||||
continue
|
||||
name = raw_name.decode("gbk", errors="replace").rstrip("\x00")
|
||||
short_name = raw_short_name.decode("gbk", errors="replace").rstrip("\x00")
|
||||
results.append(
|
||||
ExMarketInfo(
|
||||
market=market,
|
||||
category=category,
|
||||
name=name,
|
||||
short_name=short_name,
|
||||
_raw=raw,
|
||||
)
|
||||
)
|
||||
return results
|
||||
@@ -0,0 +1,74 @@
|
||||
"""获取扩展行情分时数据(当日 + 历史)。"""
|
||||
|
||||
import struct
|
||||
|
||||
from ...commands.base import BaseCommand
|
||||
from ..models import ExMinuteBar
|
||||
|
||||
|
||||
class GetExMinuteTimeDataCmd(BaseCommand[list[ExMinuteBar]]):
|
||||
"""获取当日分时行情数据。"""
|
||||
|
||||
def __init__(self, market: int, code: str) -> None:
|
||||
self.market = market
|
||||
self.code = code.encode("utf-8")
|
||||
|
||||
def build_request(self) -> bytes:
|
||||
header = bytes.fromhex("01 07 08 00 01 01 0c 00 0c 00 0b 24")
|
||||
return header + struct.pack("<B9s", self.market, self.code)
|
||||
|
||||
def parse_response(self, body: bytes) -> list[ExMinuteBar]:
|
||||
if len(body) < 12:
|
||||
return []
|
||||
pos = 0
|
||||
(market, raw_code, num) = struct.unpack("<B9sH", body[pos : pos + 12])
|
||||
pos += 12
|
||||
return self._parse_records(body, pos, num)
|
||||
|
||||
@staticmethod
|
||||
def _parse_records(body: bytes, pos: int, num: int) -> list[ExMinuteBar]:
|
||||
results: list[ExMinuteBar] = []
|
||||
for _ in range(num):
|
||||
if pos + 18 > len(body):
|
||||
break
|
||||
record_start = pos
|
||||
(raw_time, price, avg_price, volume, amount) = struct.unpack(
|
||||
"<HffII",
|
||||
body[pos : pos + 18],
|
||||
)
|
||||
pos += 18
|
||||
hour = raw_time // 60
|
||||
minute = raw_time % 60
|
||||
results.append(
|
||||
ExMinuteBar(
|
||||
hour=hour,
|
||||
minute=minute,
|
||||
price=price,
|
||||
avg_price=avg_price,
|
||||
volume=volume,
|
||||
open_interest=amount,
|
||||
_raw=body[record_start:pos],
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
class GetExHistoryMinuteTimeDataCmd(BaseCommand[list[ExMinuteBar]]):
|
||||
"""获取历史某日分时行情数据。"""
|
||||
|
||||
def __init__(self, market: int, code: str, date: int) -> None:
|
||||
self.market = market
|
||||
self.code = code.encode("utf-8")
|
||||
self.date = date
|
||||
|
||||
def build_request(self) -> bytes:
|
||||
header = bytes.fromhex("01 01 30 00 01 01 10 00 10 00 0c 24")
|
||||
return header + struct.pack("<IB9s", self.date, self.market, self.code)
|
||||
|
||||
def parse_response(self, body: bytes) -> list[ExMinuteBar]:
|
||||
if len(body) < 20:
|
||||
return []
|
||||
pos = 0
|
||||
(_market, _code, _unk, num) = struct.unpack("<B9s8sH", body[pos : pos + 20])
|
||||
pos += 20
|
||||
return GetExMinuteTimeDataCmd._parse_records(body, pos, num)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""获取扩展行情成交数据(当日 + 历史)。"""
|
||||
|
||||
import struct
|
||||
|
||||
from ...commands.base import BaseCommand
|
||||
from ..models import ExTransactionRecord
|
||||
|
||||
|
||||
class GetExTransactionDataCmd(BaseCommand[list[ExTransactionRecord]]):
|
||||
"""获取当日分笔成交数据。"""
|
||||
|
||||
def __init__(self, market: int, code: str, start: int = 0, count: int = 1800) -> None:
|
||||
self.market = market
|
||||
self.code = code.encode("utf-8")
|
||||
self.start = start
|
||||
self.count = count
|
||||
|
||||
def build_request(self) -> bytes:
|
||||
header = bytes.fromhex("01 01 08 00 03 01 12 00 12 00 fc 23")
|
||||
return header + struct.pack("<B9siH", self.market, self.code, self.start, self.count)
|
||||
|
||||
def parse_response(self, body: bytes) -> list[ExTransactionRecord]:
|
||||
if len(body) < 16:
|
||||
return []
|
||||
pos = 0
|
||||
(_market, _code, _unk, num) = struct.unpack("<B9s4sH", body[pos : pos + 16])
|
||||
pos += 16
|
||||
return self._parse_records(body, pos, num)
|
||||
|
||||
@staticmethod
|
||||
def _parse_records(body: bytes, pos: int, num: int) -> list[ExTransactionRecord]:
|
||||
results: list[ExTransactionRecord] = []
|
||||
for _ in range(num):
|
||||
if pos + 16 > len(body):
|
||||
break
|
||||
record_start = pos
|
||||
(raw_time, price, volume, zengcang, direction) = struct.unpack(
|
||||
"<HIIiH",
|
||||
body[pos : pos + 16],
|
||||
)
|
||||
pos += 16
|
||||
hour = raw_time // 60
|
||||
minute = raw_time % 60
|
||||
second = direction % 10000
|
||||
if second > 59:
|
||||
second = 0
|
||||
nature = direction // 10000
|
||||
results.append(
|
||||
ExTransactionRecord(
|
||||
hour=hour,
|
||||
minute=minute,
|
||||
second=second,
|
||||
price=price,
|
||||
volume=volume,
|
||||
zengcang=zengcang,
|
||||
nature=nature,
|
||||
_raw=body[record_start:pos],
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
class GetExHistoryTransactionDataCmd(BaseCommand[list[ExTransactionRecord]]):
|
||||
"""获取历史某日分笔成交数据。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
market: int,
|
||||
code: str,
|
||||
date: int,
|
||||
start: int = 0,
|
||||
count: int = 1800,
|
||||
) -> 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 = bytes.fromhex("01 01 30 00 02 01 16 00 16 00 06 24")
|
||||
return header + struct.pack(
|
||||
"<IB9siH",
|
||||
self.date,
|
||||
self.market,
|
||||
self.code,
|
||||
self.start,
|
||||
self.count,
|
||||
)
|
||||
|
||||
def parse_response(self, body: bytes) -> list[ExTransactionRecord]:
|
||||
if len(body) < 16:
|
||||
return []
|
||||
pos = 0
|
||||
(_market, _code, _unk, num) = struct.unpack("<B9s4sH", body[pos : pos + 16])
|
||||
pos += 16
|
||||
return GetExTransactionDataCmd._parse_records(body, pos, num)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""扩展行情握手命令。"""
|
||||
|
||||
from typing import Final
|
||||
|
||||
EX_SETUP_CMD: Final[bytes] = bytes.fromhex(
|
||||
"01 01 48 65 00 01 52 00 52 00 54 24"
|
||||
"1f 32 c6 e5 d5 3d fb 41"
|
||||
"1f 32 c6 e5 d5 3d fb 41"
|
||||
"1f 32 c6 e5 d5 3d fb 41"
|
||||
"1f 32 c6 e5 d5 3d fb 41"
|
||||
"1f 32 c6 e5 d5 3d fb 41"
|
||||
"1f 32 c6 e5 d5 3d fb 41"
|
||||
"1f 32 c6 e5 d5 3d fb 41"
|
||||
"cc e1 6d ff d5 ba 3f b8"
|
||||
"cb c5 7a 05 4f 77 48 ea"
|
||||
)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""扩展行情数据模型与常量。"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# 扩展行情服务器(端口 7727),来源: pytdx_backup/util/best_ip.py
|
||||
KNOWN_EX_HOSTS: list[str] = [
|
||||
"106.14.95.149",
|
||||
"112.74.214.43",
|
||||
"119.147.86.171",
|
||||
"119.97.185.5",
|
||||
"120.24.0.77",
|
||||
"47.92.127.181",
|
||||
"59.175.238.38",
|
||||
"61.152.107.141",
|
||||
"61.152.107.171",
|
||||
"47.107.75.159",
|
||||
"120.25.218.6",
|
||||
"43.139.173.246",
|
||||
"159.75.90.107",
|
||||
"106.52.170.195",
|
||||
"139.9.191.175",
|
||||
"175.24.47.69",
|
||||
"150.158.9.199",
|
||||
"150.158.20.127",
|
||||
"49.235.119.116",
|
||||
"49.234.13.160",
|
||||
"116.205.143.214",
|
||||
"124.71.223.19",
|
||||
"113.45.175.47",
|
||||
"123.60.173.210",
|
||||
"118.89.69.202",
|
||||
]
|
||||
|
||||
# 已知扩展行情市场代码
|
||||
KNOWN_EX_MARKETS: dict[int, str] = {
|
||||
0: "深圳",
|
||||
1: "上海",
|
||||
28: "郑州商品",
|
||||
29: "大连商品",
|
||||
30: "上海期货",
|
||||
31: "香港主板",
|
||||
47: "中金所",
|
||||
48: "香港创业板",
|
||||
49: "香港基金",
|
||||
71: "沪港通",
|
||||
74: "外盘",
|
||||
}
|
||||
|
||||
_DEFAULT_EX_PORT = 7727
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExMarketInfo:
|
||||
"""市场定义(GetMarkets 返回)。"""
|
||||
|
||||
market: int
|
||||
category: int
|
||||
name: str
|
||||
short_name: str
|
||||
_raw: bytes = field(default=b"", repr=False, compare=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExInstrumentInfo:
|
||||
"""合约/证券信息(GetInstrumentInfo 返回)。"""
|
||||
|
||||
category: int
|
||||
market: int
|
||||
code: str
|
||||
name: str
|
||||
desc: str
|
||||
_raw: bytes = field(default=b"", repr=False, compare=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExInstrumentQuote:
|
||||
"""五档行情(GetInstrumentQuote 返回)。"""
|
||||
|
||||
market: int
|
||||
code: str
|
||||
pre_close: float
|
||||
open: float
|
||||
high: float
|
||||
low: float
|
||||
price: float
|
||||
kaicang: int
|
||||
zongliang: int
|
||||
xianliang: int
|
||||
neipan: int
|
||||
waipan: int
|
||||
chicang: int
|
||||
bid1: float
|
||||
bid2: float
|
||||
bid3: float
|
||||
bid4: float
|
||||
bid5: float
|
||||
bid_vol1: int
|
||||
bid_vol2: int
|
||||
bid_vol3: int
|
||||
bid_vol4: int
|
||||
bid_vol5: int
|
||||
ask1: float
|
||||
ask2: float
|
||||
ask3: float
|
||||
ask4: float
|
||||
ask5: float
|
||||
ask_vol1: int
|
||||
ask_vol2: int
|
||||
ask_vol3: int
|
||||
ask_vol4: int
|
||||
ask_vol5: int
|
||||
_raw: bytes = field(default=b"", repr=False, compare=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExInstrumentBar:
|
||||
"""K线数据(GetInstrumentBars / GetHistoryInstrumentBarsRange 返回)。"""
|
||||
|
||||
open: float
|
||||
high: float
|
||||
low: float
|
||||
close: float
|
||||
position: int
|
||||
trade: int
|
||||
amount: float
|
||||
year: int
|
||||
month: int
|
||||
day: int
|
||||
hour: int
|
||||
minute: int
|
||||
_raw: bytes = field(default=b"", repr=False, compare=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExMinuteBar:
|
||||
"""分时数据(GetMinuteTimeData / GetHistoryMinuteTimeData 返回)。"""
|
||||
|
||||
hour: int
|
||||
minute: int
|
||||
price: float
|
||||
avg_price: float
|
||||
volume: int
|
||||
open_interest: int
|
||||
_raw: bytes = field(default=b"", repr=False, compare=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExTransactionRecord:
|
||||
"""逐笔成交记录(GetTransactionData / GetHistoryTransactionData 返回)。"""
|
||||
|
||||
hour: int
|
||||
minute: int
|
||||
second: int
|
||||
price: int
|
||||
volume: int
|
||||
zengcang: int
|
||||
nature: int
|
||||
_raw: bytes = field(default=b"", repr=False, compare=False)
|
||||
@@ -0,0 +1 @@
|
||||
"""扩展行情传输层。"""
|
||||
@@ -0,0 +1,125 @@
|
||||
"""扩展行情异步 TCP 连接(asyncio,端口 7727)。"""
|
||||
|
||||
import asyncio
|
||||
from types import TracebackType
|
||||
from typing import TYPE_CHECKING, TypeVar
|
||||
|
||||
from ...codec.frame import HEADER_SIZE, decompress_body, parse_header
|
||||
from ...exceptions import TdxConnectionError
|
||||
from ..commands.setup import EX_SETUP_CMD
|
||||
from ..models import KNOWN_EX_HOSTS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...commands.base import BaseCommand
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
_DEFAULT_EX_PORT = 7727
|
||||
_DEFAULT_TIMEOUT = 15.0
|
||||
|
||||
|
||||
class AsyncExTdxConnection:
|
||||
"""扩展行情异步 TCP 连接(asyncio,端口 7727,单包握手)。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = KNOWN_EX_HOSTS[0],
|
||||
port: int = _DEFAULT_EX_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
|
||||
self._io_lock = asyncio.Lock()
|
||||
|
||||
async def connect(self) -> None:
|
||||
async with self._io_lock:
|
||||
if self._writer is not None and not self._writer.is_closing():
|
||||
return
|
||||
await self._connect_unlocked()
|
||||
|
||||
async def close(self) -> None:
|
||||
async with self._io_lock:
|
||||
await self._close_unlocked()
|
||||
|
||||
async def execute(self, cmd: "BaseCommand[T]") -> T:
|
||||
async with self._io_lock:
|
||||
if self._writer is None or self._reader is None:
|
||||
raise TdxConnectionError("未连接,请先调用 connect()")
|
||||
request = cmd.build_request()
|
||||
try:
|
||||
self._writer.write(request)
|
||||
await asyncio.wait_for(self._writer.drain(), timeout=self.timeout)
|
||||
header_buf = await self._recv_exact(HEADER_SIZE)
|
||||
header = parse_header(header_buf)
|
||||
raw_body = await self._recv_exact(header.zipsize)
|
||||
except asyncio.TimeoutError as e:
|
||||
await self._close_unlocked()
|
||||
raise TdxConnectionError(f"通信超时: {self.timeout}s") from e
|
||||
except (OSError, asyncio.IncompleteReadError) as e:
|
||||
await self._close_unlocked()
|
||||
raise TdxConnectionError(f"通信错误: {e}") from e
|
||||
|
||||
body = decompress_body(header, raw_body)
|
||||
return cmd.parse_response(body)
|
||||
|
||||
async def _connect_unlocked(self) -> None:
|
||||
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
|
||||
try:
|
||||
await self._send_setup()
|
||||
except Exception:
|
||||
await self._close_unlocked()
|
||||
raise
|
||||
|
||||
async def _close_unlocked(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 __aenter__(self) -> "AsyncExTdxConnection":
|
||||
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 _send_setup(self) -> None:
|
||||
"""发送单条扩展行情握手命令并丢弃响应。"""
|
||||
assert self._writer is not None
|
||||
assert self._reader is not None
|
||||
self._writer.write(EX_SETUP_CMD)
|
||||
await asyncio.wait_for(self._writer.drain(), timeout=self.timeout)
|
||||
try:
|
||||
hdr_buf = await self._recv_exact(HEADER_SIZE)
|
||||
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:
|
||||
assert self._reader is not None
|
||||
return await asyncio.wait_for(
|
||||
self._reader.readexactly(n),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
@@ -0,0 +1,163 @@
|
||||
"""扩展行情同步 TCP 连接(端口 7727)。"""
|
||||
|
||||
import socket
|
||||
import time
|
||||
from types import TracebackType
|
||||
from typing import TYPE_CHECKING, TypeVar
|
||||
|
||||
from ...codec.frame import HEADER_SIZE, decompress_body, parse_header
|
||||
from ...exceptions import TdxConnectionError
|
||||
from ..commands.setup import EX_SETUP_CMD
|
||||
from ..models import KNOWN_EX_HOSTS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...commands.base import BaseCommand
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
_DEFAULT_EX_PORT = 7727
|
||||
_DEFAULT_TIMEOUT = 15.0
|
||||
|
||||
|
||||
def ping_ex_host(
|
||||
host: str,
|
||||
port: int = _DEFAULT_EX_PORT,
|
||||
timeout: float = 5.0,
|
||||
) -> float | None:
|
||||
"""测量扩展行情服务器延迟(秒)。失败返回 None。"""
|
||||
t0 = time.monotonic()
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout)
|
||||
try:
|
||||
sock.connect((host, port))
|
||||
sock.sendall(EX_SETUP_CMD)
|
||||
hdr_buf = _recv_exact_sock(sock, HEADER_SIZE)
|
||||
hdr = parse_header(hdr_buf)
|
||||
if hdr.zipsize > 0:
|
||||
_recv_exact_sock(sock, hdr.zipsize)
|
||||
return time.monotonic() - t0
|
||||
except OSError:
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def ping_ex_all(
|
||||
hosts: list[str] | None = None,
|
||||
port: int = _DEFAULT_EX_PORT,
|
||||
timeout: float = 5.0,
|
||||
) -> list[tuple[str, float]]:
|
||||
"""并发测量多台扩展行情服务器延迟,按延迟排序返回。"""
|
||||
import concurrent.futures
|
||||
|
||||
if hosts is None:
|
||||
hosts = KNOWN_EX_HOSTS
|
||||
results: list[tuple[str, float]] = []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=len(hosts)) as pool:
|
||||
futures = {pool.submit(ping_ex_host, h, port, timeout): h for h in hosts}
|
||||
for fut in concurrent.futures.as_completed(futures):
|
||||
host = futures[fut]
|
||||
latency = fut.result()
|
||||
if latency is not None:
|
||||
results.append((host, latency))
|
||||
results.sort(key=lambda t: t[1])
|
||||
return results
|
||||
|
||||
|
||||
def _recv_exact_sock(sock: socket.socket, n: int) -> bytes:
|
||||
buf = bytearray()
|
||||
while len(buf) < n:
|
||||
chunk = sock.recv(n - len(buf))
|
||||
if not chunk:
|
||||
raise TdxConnectionError("连接被服务器关闭")
|
||||
buf.extend(chunk)
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
class ExTdxConnection:
|
||||
"""扩展行情同步 TCP 连接(端口 7727,单包握手)。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = KNOWN_EX_HOSTS[0],
|
||||
port: int = _DEFAULT_EX_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 连接并完成扩展行情握手。"""
|
||||
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
|
||||
try:
|
||||
self._send_setup()
|
||||
except Exception:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._sock = None
|
||||
raise
|
||||
|
||||
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)
|
||||
|
||||
def __enter__(self) -> "ExTdxConnection":
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: TracebackType | None,
|
||||
) -> None:
|
||||
self.close()
|
||||
|
||||
def _send_setup(self) -> None:
|
||||
"""发送单条扩展行情握手命令并丢弃响应。"""
|
||||
assert self._sock is not None
|
||||
self._sock.sendall(EX_SETUP_CMD)
|
||||
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:
|
||||
assert self._sock is not None
|
||||
return _recv_exact_sock(self._sock, n)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""easy-tdx 异常层次"""
|
||||
|
||||
|
||||
class TdxError(Exception):
|
||||
"""所有 easy-tdx 异常的基类"""
|
||||
|
||||
|
||||
class TdxConnectionError(TdxError):
|
||||
"""TCP 连接失败或超时"""
|
||||
|
||||
|
||||
class TdxDecodeError(TdxError):
|
||||
"""响应报文解析失败"""
|
||||
|
||||
|
||||
class TdxCommandError(TdxError):
|
||||
"""命令执行失败(服务器返回错误)"""
|
||||
|
||||
|
||||
class TdxFileNotFoundError(TdxError):
|
||||
"""本地数据文件不存在"""
|
||||
|
||||
|
||||
class TdxOfflineError(TdxError):
|
||||
"""离线数据读取失败(路径未配置、文件格式错误等)"""
|
||||
@@ -0,0 +1,29 @@
|
||||
from .bar import SecurityBar
|
||||
from .enums import KlineCategory, Market
|
||||
from .finance import (
|
||||
XDXR_CATEGORY_NAMES,
|
||||
CompanyInfoCategory,
|
||||
FinanceInfo,
|
||||
FinancialFileInfo,
|
||||
FinancialRecord,
|
||||
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",
|
||||
"FinancialFileInfo",
|
||||
"FinancialRecord",
|
||||
]
|
||||
@@ -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,24 @@
|
||||
"""市场代码与 K 线周期枚举"""
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class Market(IntEnum):
|
||||
SZ = 0 # 深圳
|
||||
SH = 1 # 上海
|
||||
BJ = 2 # 北京
|
||||
|
||||
|
||||
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,157 @@
|
||||
"""财务与公司信息模型"""
|
||||
|
||||
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 # 每股分红(元;协议原值按每10股)
|
||||
peigujia: float | None = None # 配股价(元/股)
|
||||
songzhuangu: float | None = None # 每股送转股比例(协议原值按每10股)
|
||||
peigu: float | None = None # 每股配股比例(协议原值按每10股)
|
||||
|
||||
# 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:
|
||||
"""公司信息文件目录条目"""
|
||||
|
||||
name: str = "" # 目录名(如“最新提示”)
|
||||
filename: str = "" # 文件名(如 '600000.txt')
|
||||
start: int = 0 # 内容起始偏移
|
||||
length: int = 0 # 内容长度(字节)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FinancialFileInfo:
|
||||
"""财报 zip 文件索引条目(来自 tdxfin/gpcw.txt)。"""
|
||||
|
||||
filename: str # "gpcw20260331.zip"
|
||||
hash: str # MD5 hex digest
|
||||
filesize: int # 字节
|
||||
|
||||
|
||||
@dataclass
|
||||
class FinancialRecord:
|
||||
"""单只股票的一期历史专业财报记录。"""
|
||||
|
||||
code: str # 6 位股票代码
|
||||
market: Market # 市场
|
||||
report_date: int # 报告期 YYYYMMDD
|
||||
fields: list[float] # N 个浮点字段(N = report_size / 4)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TdxBlock:
|
||||
"""通达信板块信息(行业、概念、风格等)"""
|
||||
|
||||
name: str # 板块名称(如“房地产”)
|
||||
category: int # 板块分类(0=行业, 1=地域, 2=概念, 3=风格, 等)
|
||||
count: int # 板块包含股票数量
|
||||
codes: list[str] # 股票代码列表(6位数字代码)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""实时行情五档报价模型"""
|
||||
|
||||
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)
|
||||
limit_up: float | None # 涨停价(业务规则计算)
|
||||
limit_down: float | None # 跌停价(业务规则计算)
|
||||
|
||||
# 未知字段:买卖量之后的两个变长整数(保留供进一步分析)
|
||||
unknown_2: int = field(default=0, repr=False) # 未知变长整数 2
|
||||
unknown_3: int = field(default=0, repr=False) # 未知变长整数 3
|
||||
|
||||
# 未知字段:尾部四个变长整数
|
||||
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,23 @@
|
||||
"""证券基本信息模型"""
|
||||
|
||||
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 # 昨收价(通达信自定义浮点解码)
|
||||
|
||||
# 扩展字段(通过 get_security_list_all 关联 tdxhy.cfg 获得)
|
||||
industry_tdx: str = "" # 通达信行业代码 (如 T1001)
|
||||
industry_sw: str = "" # 申万行业代码 (如 X500102)
|
||||
|
||||
_raw: bytes = field(default=b"", repr=False, compare=False)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""验证市场概况模型。"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarketStat:
|
||||
"""全市场涨跌统计概况。"""
|
||||
up_count: int # 上涨家数
|
||||
down_count: int # 下跌家数
|
||||
neutral_count: int # 平盘家数
|
||||
suspended_count: int # 由 total-(up+down+neutral) 得到的残差项,近似表示停牌/未参与统计家数
|
||||
total_count: int # 总计(包含停牌)
|
||||
total_amount: float # 总成交额
|
||||
total_volume: float # 总成交量
|
||||
total_market_cap: float # 总市值(亿元),来自 880001 收盘价,÷100 得万亿
|
||||
limit_up_count: int # 涨停家数,来自 880006 close
|
||||
limit_down_count: int # 跌停家数,来自 880006 open
|
||||
|
||||
|
||||
@dataclass
|
||||
class FundFlow:
|
||||
"""个股资金流向统计(基于 Tick 数据加权计算)。"""
|
||||
|
||||
# 流入项 (Buy)
|
||||
super_in: float # 超大单流入 (>100万)
|
||||
large_in: float # 大单流入 (>20万 且 <=100万)
|
||||
medium_in: float # 中单流入 (>4万 且 <=20万)
|
||||
small_in: float # 小单流入 (<=4万)
|
||||
|
||||
# 流出项 (Sell)
|
||||
super_out: float
|
||||
large_out: float
|
||||
medium_out: float
|
||||
small_out: float
|
||||
|
||||
@property
|
||||
def main_net_inflow(self) -> float:
|
||||
"""主力净流入 (超大单 + 大单)。"""
|
||||
return (self.super_in + self.large_in) - (self.super_out + self.large_out)
|
||||
|
||||
@property
|
||||
def total_net_inflow(self) -> float:
|
||||
"""全单净流入。"""
|
||||
return (self.super_in + self.large_in + self.medium_in + self.small_in) - \
|
||||
(self.super_out + self.large_out + self.medium_out + self.small_out)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HistoricalFundFlow:
|
||||
"""历史日线资金流向条目。"""
|
||||
|
||||
year: int
|
||||
month: int
|
||||
day: int
|
||||
|
||||
# 金额项 (单位:元)
|
||||
super_in: float
|
||||
super_out: float
|
||||
large_in: float
|
||||
large_out: float
|
||||
medium_in: float
|
||||
medium_out: float
|
||||
small_in: float
|
||||
small_out: float
|
||||
|
||||
@property
|
||||
def main_net_inflow(self) -> float:
|
||||
"""当日主力净流入。"""
|
||||
return (self.super_in + self.large_in) - (self.super_out + self.large_out)
|
||||
@@ -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=中性/撮合, 8=集合竞价
|
||||
|
||||
# pytdx 中被丢弃的字段
|
||||
unknown_last: int = field(default=0, repr=False)
|
||||
|
||||
_raw: bytes = field(default=b"", repr=False, compare=False)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""离线数据读取模块 —— 从本地通达信安装目录读取数据文件。"""
|
||||
|
||||
from .block import CustomerBlock, read_block_dat, read_customer_blocks
|
||||
from .daily_bar import find_daily_bar_file, read_daily_bars
|
||||
from .ex_daily_bar import ExDailyBar, read_ex_daily_bars
|
||||
from .finders import find_5min_bar_file, find_lc1_bar_file, find_lc5_bar_file
|
||||
from .gbbq import GbbqRecord, read_gbbq
|
||||
from .history_financial import read_history_financial
|
||||
from .min_bar import read_5min_bars, read_lc_min_bars
|
||||
from .paths import detect_tdx_home, resolve_vipdoc
|
||||
|
||||
__all__ = [
|
||||
# 路径
|
||||
"detect_tdx_home",
|
||||
"resolve_vipdoc",
|
||||
# 日线
|
||||
"read_daily_bars",
|
||||
"find_daily_bar_file",
|
||||
# 分钟线
|
||||
"read_5min_bars",
|
||||
"read_lc_min_bars",
|
||||
"find_5min_bar_file",
|
||||
"find_lc1_bar_file",
|
||||
"find_lc5_bar_file",
|
||||
# 扩展市场
|
||||
"ExDailyBar",
|
||||
"read_ex_daily_bars",
|
||||
# 板块
|
||||
"CustomerBlock",
|
||||
"read_block_dat",
|
||||
"read_customer_blocks",
|
||||
# 股本变迁
|
||||
"GbbqRecord",
|
||||
"read_gbbq",
|
||||
# 历史财务
|
||||
"read_history_financial",
|
||||
]
|
||||
@@ -0,0 +1,92 @@
|
||||
"""板块数据读取(.dat 文件和自定义板块目录)。"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from ..codec.block import parse_block_dat
|
||||
from ..exceptions import TdxFileNotFoundError, TdxOfflineError
|
||||
from ..models.finance import TdxBlock
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomerBlock:
|
||||
"""自定义板块。"""
|
||||
|
||||
blockname: str
|
||||
block_type: str
|
||||
codes: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def read_block_dat(filepath: str | Path) -> list[TdxBlock]:
|
||||
"""从本地 .dat 板块文件读取板块数据。
|
||||
|
||||
直接复用 codec/block.py 的 parse_block_dat()。
|
||||
|
||||
Args:
|
||||
filepath: .dat 文件路径(如 block_zs.dat)。
|
||||
|
||||
Returns:
|
||||
TdxBlock 列表。
|
||||
"""
|
||||
filepath = Path(filepath)
|
||||
if not filepath.is_file():
|
||||
raise TdxFileNotFoundError(f"板块数据文件不存在: {filepath}")
|
||||
data = filepath.read_bytes()
|
||||
return parse_block_dat(data, filename=filepath.name)
|
||||
|
||||
|
||||
def read_customer_blocks(block_dir: str | Path) -> list[CustomerBlock]:
|
||||
"""从通达信自定义板块目录读取板块数据。
|
||||
|
||||
目录结构:
|
||||
blocknew.cfg — 板块索引(120 字节/条:50B 名称 + 70B 文件名)
|
||||
*.blk — 板块内容(每行一个代码,首位为市场标识)
|
||||
|
||||
Args:
|
||||
block_dir: 自定义板块目录路径。
|
||||
|
||||
Returns:
|
||||
CustomerBlock 列表。
|
||||
"""
|
||||
block_dir = Path(block_dir)
|
||||
if not block_dir.is_dir():
|
||||
raise TdxOfflineError(f"自定义板块目录不存在: {block_dir}")
|
||||
|
||||
cfg_path = block_dir / "blocknew.cfg"
|
||||
if not cfg_path.is_file():
|
||||
raise TdxOfflineError(f"板块配置文件不存在: {cfg_path}")
|
||||
|
||||
cfg_data = cfg_path.read_bytes()
|
||||
results: list[CustomerBlock] = []
|
||||
pos = 0
|
||||
|
||||
while pos + 120 <= len(cfg_data):
|
||||
name = cfg_data[pos : pos + 50].decode("gbk", errors="replace").rstrip("\x00")
|
||||
name = name.split("\x00")[0]
|
||||
blk_filename = cfg_data[pos + 50 : pos + 120].decode("gbk", errors="replace").rstrip("\x00")
|
||||
blk_filename = blk_filename.split("\x00")[0]
|
||||
pos += 120
|
||||
|
||||
if not blk_filename:
|
||||
continue
|
||||
|
||||
blk_path = block_dir / f"{blk_filename}.blk"
|
||||
if not blk_path.is_file():
|
||||
continue
|
||||
|
||||
codes: list[str] = []
|
||||
for line in blk_path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
line = line.strip()
|
||||
if line and len(line) > 1:
|
||||
codes.append(line[1:]) # 去掉首位的市场标识
|
||||
|
||||
if name:
|
||||
results.append(
|
||||
CustomerBlock(
|
||||
blockname=name,
|
||||
block_type=blk_filename,
|
||||
codes=codes,
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,131 @@
|
||||
"""日线 K 线数据读取(.day 文件)。"""
|
||||
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
from ..exceptions import TdxFileNotFoundError
|
||||
from ..models.bar import SecurityBar
|
||||
from .paths import _market_to_exchange, resolve_vipdoc
|
||||
|
||||
# struct 格式:日期(YYYYMMDD) 开盘 最高 最低 收盘 成交额 成交量 保留
|
||||
# 全部为小端序,32 字节/条
|
||||
_DAILY_FMT = struct.Struct("<IIIIIfII")
|
||||
|
||||
# 证券类型 → (价格系数, 量系数)
|
||||
_SECURITY_COEFFICIENTS: dict[str, tuple[float, float]] = {
|
||||
"SH_A_STOCK": (0.01, 0.01),
|
||||
"SH_B_STOCK": (0.001, 0.01),
|
||||
"SH_INDEX": (0.01, 1.0),
|
||||
"SH_FUND": (0.001, 1.0),
|
||||
"SH_BOND": (0.001, 1.0),
|
||||
"SZ_A_STOCK": (0.01, 0.01),
|
||||
"SZ_B_STOCK": (0.01, 0.01),
|
||||
"SZ_INDEX": (0.01, 1.0),
|
||||
"SZ_FUND": (0.001, 0.01),
|
||||
"SZ_BOND": (0.001, 1.0),
|
||||
}
|
||||
|
||||
|
||||
def _detect_security_type(filename: str) -> str:
|
||||
"""从文件名推断证券类型。
|
||||
|
||||
文件名格式: {exchange}{code}.day,如 sh600000.day、sz000001.day
|
||||
"""
|
||||
base = Path(filename).name.lower()
|
||||
exchange = base[:2] # "sh" or "sz"
|
||||
code_head = base[2:4]
|
||||
|
||||
if exchange == "sz":
|
||||
if code_head in ("00", "30"):
|
||||
return "SZ_A_STOCK"
|
||||
if code_head == "20":
|
||||
return "SZ_B_STOCK"
|
||||
if code_head == "39":
|
||||
return "SZ_INDEX"
|
||||
if code_head in ("15", "16"):
|
||||
return "SZ_FUND"
|
||||
if code_head in ("10", "11", "12", "13", "14"):
|
||||
return "SZ_BOND"
|
||||
elif exchange == "sh":
|
||||
if code_head == "60":
|
||||
return "SH_A_STOCK"
|
||||
if code_head == "90":
|
||||
return "SH_B_STOCK"
|
||||
if code_head in ("00", "88", "99"):
|
||||
return "SH_INDEX"
|
||||
if code_head in ("50", "51"):
|
||||
return "SH_FUND"
|
||||
if code_head in ("01", "10", "11", "12", "13", "14"):
|
||||
return "SH_BOND"
|
||||
|
||||
return "SZ_A_STOCK" # 默认按 A 股处理
|
||||
|
||||
|
||||
def read_daily_bars(filepath: str | Path) -> list[SecurityBar]:
|
||||
"""从本地 .day 文件读取日线 K 线数据。
|
||||
|
||||
Args:
|
||||
filepath: .day 文件路径。
|
||||
|
||||
Returns:
|
||||
SecurityBar 列表(按时间升序)。
|
||||
"""
|
||||
filepath = Path(filepath)
|
||||
if not filepath.is_file():
|
||||
raise TdxFileNotFoundError(f"日线数据文件不存在: {filepath}")
|
||||
|
||||
sec_type = _detect_security_type(filepath.name)
|
||||
price_coeff, vol_coeff = _SECURITY_COEFFICIENTS.get(sec_type, (0.01, 0.01))
|
||||
|
||||
data = filepath.read_bytes()
|
||||
if len(data) < _DAILY_FMT.size:
|
||||
return []
|
||||
|
||||
results: list[SecurityBar] = []
|
||||
record_size = _DAILY_FMT.size
|
||||
for offset in range(0, len(data) - record_size + 1, record_size):
|
||||
raw = data[offset : offset + record_size]
|
||||
date_int, op, hi, lo, cl, amount, vol, _res = _DAILY_FMT.unpack(raw)
|
||||
|
||||
year = date_int // 10000
|
||||
month = (date_int % 10000) // 100
|
||||
day = date_int % 100
|
||||
|
||||
results.append(
|
||||
SecurityBar(
|
||||
open=op * price_coeff,
|
||||
close=cl * price_coeff,
|
||||
high=hi * price_coeff,
|
||||
low=lo * price_coeff,
|
||||
vol=vol * vol_coeff,
|
||||
amount=amount,
|
||||
year=year,
|
||||
month=month,
|
||||
day=day,
|
||||
hour=0,
|
||||
minute=0,
|
||||
_raw=raw,
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def find_daily_bar_file(
|
||||
market: int,
|
||||
code: str,
|
||||
vipdoc: str | Path | None = None,
|
||||
) -> Path:
|
||||
"""根据市场和代码定位日线文件路径。
|
||||
|
||||
Args:
|
||||
market: 市场代码(Market.SZ=0, Market.SH=1)。
|
||||
code: 6 位股票代码。
|
||||
vipdoc: vipdoc 目录路径,None 则自动检测。
|
||||
|
||||
Returns:
|
||||
.day 文件的 Path。
|
||||
"""
|
||||
vipdoc_path = resolve_vipdoc(vipdoc)
|
||||
exchange = _market_to_exchange(market)
|
||||
return vipdoc_path / exchange / "lday" / f"{exchange}{code}.day"
|
||||
@@ -0,0 +1,82 @@
|
||||
"""扩展市场日线数据读取(期货、港股等 .day 文件)。"""
|
||||
|
||||
import struct
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from ..exceptions import TdxFileNotFoundError
|
||||
|
||||
# 日期(4B) 开盘(4Bf) 最高(4Bf) 最低(4Bf) 收盘(4Bf) 成交额(4B) 成交量(4B) 结算价(4Bf)
|
||||
_EX_DAILY_FMT = struct.Struct("<IffffIIf")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExDailyBar:
|
||||
"""扩展市场日线(期货/港股等,含结算价)。"""
|
||||
|
||||
open: float
|
||||
high: float
|
||||
low: float
|
||||
close: float
|
||||
amount: int
|
||||
vol: int
|
||||
settlement: float
|
||||
hk_stock_amount: float
|
||||
year: int
|
||||
month: int
|
||||
day: int
|
||||
_raw: bytes = field(default=b"", repr=False, compare=False)
|
||||
|
||||
|
||||
def read_ex_daily_bars(filepath: str | Path) -> list[ExDailyBar]:
|
||||
"""从本地扩展市场 .day 文件读取日线数据。
|
||||
|
||||
文件位于 vipdoc/ds/ 目录下,如 29#A1801.day。
|
||||
|
||||
Args:
|
||||
filepath: .day 文件路径。
|
||||
|
||||
Returns:
|
||||
ExDailyBar 列表(按时间升序)。
|
||||
"""
|
||||
filepath = Path(filepath)
|
||||
if not filepath.is_file():
|
||||
raise TdxFileNotFoundError(f"扩展市场日线文件不存在: {filepath}")
|
||||
|
||||
data = filepath.read_bytes()
|
||||
if len(data) < _EX_DAILY_FMT.size:
|
||||
return []
|
||||
|
||||
results: list[ExDailyBar] = []
|
||||
record_size = _EX_DAILY_FMT.size
|
||||
|
||||
for offset in range(0, len(data) - record_size + 1, record_size):
|
||||
raw = data[offset : offset + record_size]
|
||||
date_int, op, hi, lo, cl, amt, vol, settlement = _EX_DAILY_FMT.unpack(raw)
|
||||
|
||||
# 第 5 个字段(成交额位置)重新解释为 float 作为港股量
|
||||
hk_bytes = struct.pack("<I", amt)
|
||||
(hk_stock_amount,) = struct.unpack("<f", hk_bytes)
|
||||
|
||||
year = date_int // 10000
|
||||
month = (date_int % 10000) // 100
|
||||
day = date_int % 100
|
||||
|
||||
results.append(
|
||||
ExDailyBar(
|
||||
open=op,
|
||||
high=hi,
|
||||
low=lo,
|
||||
close=cl,
|
||||
amount=vol,
|
||||
vol=vol,
|
||||
settlement=settlement,
|
||||
hk_stock_amount=hk_stock_amount,
|
||||
year=year,
|
||||
month=month,
|
||||
day=day,
|
||||
_raw=raw,
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,38 @@
|
||||
"""路径定位辅助函数。"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import _market_to_exchange, resolve_vipdoc
|
||||
|
||||
|
||||
def find_5min_bar_file(
|
||||
market: int,
|
||||
code: str,
|
||||
vipdoc: str | Path | None = None,
|
||||
) -> Path:
|
||||
"""根据市场和代码定位 .5 分钟线文件路径。"""
|
||||
vipdoc_path = resolve_vipdoc(vipdoc)
|
||||
exchange = _market_to_exchange(market)
|
||||
return vipdoc_path / exchange / "fzline" / f"{exchange}{code}.5"
|
||||
|
||||
|
||||
def find_lc1_bar_file(
|
||||
market: int,
|
||||
code: str,
|
||||
vipdoc: str | Path | None = None,
|
||||
) -> Path:
|
||||
"""根据市场和代码定位 .lc1 分钟线文件路径。"""
|
||||
vipdoc_path = resolve_vipdoc(vipdoc)
|
||||
exchange = _market_to_exchange(market)
|
||||
return vipdoc_path / exchange / "fzline" / f"{exchange}{code}.lc1"
|
||||
|
||||
|
||||
def find_lc5_bar_file(
|
||||
market: int,
|
||||
code: str,
|
||||
vipdoc: str | Path | None = None,
|
||||
) -> Path:
|
||||
"""根据市场和代码定位 .lc5 分钟线文件路径。"""
|
||||
vipdoc_path = resolve_vipdoc(vipdoc)
|
||||
exchange = _market_to_exchange(market)
|
||||
return vipdoc_path / exchange / "fzline" / f"{exchange}{code}.lc5"
|
||||
@@ -0,0 +1,366 @@
|
||||
"""股本变迁数据读取(XOR 加密的 gbbq 文件)。"""
|
||||
|
||||
import struct
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from ..exceptions import TdxFileNotFoundError
|
||||
|
||||
_MASK32 = 0xFFFFFFFF
|
||||
|
||||
# XOR 解密密钥(从 pytdx 移植,1072 字节 hex dump)
|
||||
_HEX_KEY = (
|
||||
"38 A7 C2 1D E0 6A 17 E2 D1 39 A2 40 9C BA 46 AF "
|
||||
"42 C6 FF 05 74 EA DA BB 89 B4 F8 44 AC 89 D7 F2 "
|
||||
"98 7F B6 BC E4 F7 6B 75 05 04 58 67 79 C8 6D C6 "
|
||||
"2B 06 96 8C FB 86 06 8B BF D6 E8 E1 87 49 6B 36 "
|
||||
"C7 18 02 79 53 25 72 72 13 CC 04 0B 90 24 0C DC "
|
||||
"DB 03 1A D5 2E 04 85 5C 7E 8E BD 02 26 2D BD 06 "
|
||||
"1B 50 34 99 1B A2 24 04 F2 88 35 C8 89 EA D5 FB "
|
||||
"12 24 BB B5 3B 29 CA 14 A6 04 CE A9 A8 58 02 B9 "
|
||||
"AA E3 97 A3 A6 22 57 BB AD A0 22 5F EB 05 86 11 "
|
||||
"C3 ED B1 3F 39 C2 36 D1 4A 43 C8 64 4D B0 6E 3A "
|
||||
"7C 51 6D F7 8E C6 DF F3 8E A4 1E 74 9D B2 22 05 "
|
||||
"4D 07 3F 96 7F 97 F9 63 B9 C4 2B 98 75 F6 D6 84 "
|
||||
"56 DC 15 D3 52 8B 60 F3 D6 0E A9 AD 07 07 E9 02 "
|
||||
"86 58 C2 32 9C 90 BC C9 19 BF B0 54 7A F8 CC A8 "
|
||||
"27 63 82 29 EE FB 98 11 BF 35 29 62 91 93 95 FC "
|
||||
"F4 F0 08 E4 B2 3A B4 5E B3 B0 2E 3E 20 C1 D7 43 "
|
||||
"59 7D C6 29 5F 69 74 7F B2 77 E1 0E FA 85 A1 C9 "
|
||||
"77 73 83 B3 CB 1C 60 DB E9 53 69 FC B3 18 59 15 "
|
||||
"0F 97 8A 7A C8 83 F5 49 DC 1B 3E 86 C1 95 45 46 "
|
||||
"E2 16 67 7F 12 35 A0 BB 27 FB CC F8 30 7E 4F C8 "
|
||||
"6D AB 18 B2 0D 01 CC 79 20 80 7B FA 37 AA 14 9E "
|
||||
"85 E8 25 E9 D4 2D 35 4E 8F D3 DE B0 06 8D 15 15 "
|
||||
"52 65 E8 39 03 28 09 02 67 99 3D 13 BA F3 68 5C "
|
||||
"4C 89 B0 E3 6B AE 16 5C 88 25 F8 33 03 19 02 5B "
|
||||
"29 7B 2A 41 2D 75 49 48 9B B3 B6 B3 BF AA DF 8C "
|
||||
"95 FE 0F 13 B8 7B 02 BB 52 E1 1C 34 C3 9B 87 59 "
|
||||
"E2 46 CC 22 77 4B D7 C4 2C 31 AA 84 7C 44 51 88 "
|
||||
"15 1A CC AE 40 9D 1F 44 97 29 98 45 60 74 47 A1 "
|
||||
"0D A5 73 F0 53 FF 01 F9 F4 9A F1 36 07 D0 2D A0 "
|
||||
"79 2D 81 23 25 AD 4B 9C C8 BC 12 55 4D D4 BB 95 "
|
||||
"B1 B9 BE 7D A6 E6 A0 53 BA 83 8C DD 7E E9 4B ED "
|
||||
"BA 28 42 D8 FF 98 69 35 CA 4E 9C 9D 57 D6 CF A0 "
|
||||
"89 5C A2 E7 54 D2 AF 4C FB 54 C4 B4 4F C3 BA F8 "
|
||||
"A2 58 69 19 79 0E A8 0E 3D C8 04 FD 26 32 C8 E1 "
|
||||
"02 8B A7 1C C3 91 25 E5 D8 49 DB DF 19 5F 16 F5 "
|
||||
"A7 8B 18 23 04 D4 BF FB 44 C4 61 7C 79 6E C8 90 "
|
||||
"15 B5 EB 50 87 CA 7A 69 47 2F AF A8 B5 A2 8A 84 "
|
||||
"C4 41 79 E8 DE 0C AC D0 D5 6F 34 C6 CB A7 76 F9 "
|
||||
"00 24 42 05 26 7E 7B 14 86 59 7B DB 1C 62 D5 B7 "
|
||||
"3E F7 17 44 27 4B D2 C6 6F FF C8 49 55 AD 65 52 "
|
||||
"2D 43 C2 33 9B 63 AB 3D 54 54 28 E2 02 65 03 9A "
|
||||
"03 4B 8F 64 1A 92 52 DE 32 D6 2B F0 BE BE 1D 54 "
|
||||
"B1 7C 70 41 9B 90 55 DA 71 55 21 B9 B6 68 90 19 "
|
||||
"5F BC AA B4 55 0E E6 81 4C A3 BE BC 64 D7 59 00 "
|
||||
"59 BD 0F 6A 57 1A A6 A0 D5 1A 0A 80 D3 09 06 73 "
|
||||
"5A 51 E2 DD 29 66 AC A0 86 29 21 2B 7A 6D 9E 3A "
|
||||
"68 D0 A3 DC A7 2B 85 A0 4C D4 F0 C5 C4 43 E4 CF "
|
||||
"0C 19 81 30 B6 F6 BE 71 F5 AC 25 AA CF 42 90 06 "
|
||||
"64 1B 45 29 FD 3A A3 B6 0B 9D 29 9F FA 31 B8 6D "
|
||||
"D8 EC 43 F5 92 7E 35 22 E0 C3 D3 09 06 61 71 DA "
|
||||
"E8 36 0A 19 F6 23 81 CB 89 E0 67 6E FE B1 E6 47 "
|
||||
"72 63 5C 25 18 E0 B4 65 85 EF B5 1B 26 23 90 89 "
|
||||
"CC EE E3 01 77 95 63 DF C4 AC BF E6 37 14 99 15 "
|
||||
"49 8A 96 02 91 AA 1D 98 21 57 5E 87 96 C7 B5 87 "
|
||||
"08 3F 58 06 52 58 17 8F AB A8 4E A1 7A 60 B1 69 "
|
||||
"5E 9C BE E2 D0 C5 12 59 DF 31 EB D2 19 54 96 E2 "
|
||||
"10 11 8E 68 B4 1A 2D D3 2F AB 12 F7 FE F3 A7 F7 "
|
||||
"61 FC F7 7C CB FC 87 8C 6A 10 40 29 7B 30 D6 0D "
|
||||
"13 4C 71 CD 5E AB 36 A2 F1 4C 05 ED 53 88 E5 FF "
|
||||
"8E 71 79 5D B5 AF D3 67 6D C4 44 6B AB C1 A7 AA "
|
||||
"38 D8 70 1E 08 E6 D2 36 7B 88 11 96 DB D2 68 D9 "
|
||||
"FF D8 50 2B 3A A9 CC 45 1A CA CD D2 05 C6 FC A0 "
|
||||
"35 0C EE 98 2B 5C B2 39 6A 27 12 8F 97 EC CB 7B "
|
||||
"B6 C0 27 F6 A7 48 75 09 82 98 CA 3A 5D E3 96 0C "
|
||||
"A5 D2 B3 6C A4 D1 1F AE 99 67 B0 3D D6 9A 7A 3E "
|
||||
"00 8B FD 45 32 F7 9F 28 7C 94 03 DB 64 AA 44 80 "
|
||||
"D2 27 AF B3 73 87 57 31 EB 08 D9 BA 73 4D 2C 77 "
|
||||
"03 BF F5 0F 47 3C 22 DA 3F B9 F1 9A 1B 22 83 16 "
|
||||
"EE F4 18 FC 08 E8 3B 30 1C 04 50 AA 4C E3 28 53 "
|
||||
"AB DE F8 5F 32 D9 E1 78 7B F1 C5 A8 CA 85 B6 9F "
|
||||
"89 1F 40 B8 2C 88 D7 C1 66 34 45 D6 46 FD 7B F3 "
|
||||
"72 A3 32 55 23 CF B5 B0 79 AB A0 F1 00 5C DB EE "
|
||||
"3F 51 AA AE C0 89 8E 47 A5 30 4E 4B DD D6 AE D8 "
|
||||
"6D 40 1C 4E 8E FB 0C 60 8D 54 1E 2F 17 B7 3A ED "
|
||||
"DE DC 81 F5 72 85 B7 A6 39 31 6F 47 50 84 43 C5 "
|
||||
"11 F3 6A 26 8E BA 7F 81 98 31 FD 13 6B 83 C9 11 "
|
||||
"61 48 64 FA E3 F5 39 2C 12 11 C1 6D 4D 03 13 A6 "
|
||||
"C2 E0 DF F5 32 8E 5B 35 A7 7F 08 F7 85 27 0D 71 "
|
||||
"9D B8 CE 9C 1E BA 77 3A F6 A1 A7 26 94 29 C0 20 "
|
||||
"10 65 75 6E EF AA 32 0C 66 91 3A 4E 0E 74 E2 8A "
|
||||
"FE B6 F8 17 C7 A7 E4 D8 35 67 2E F0 83 A8 9F A6 "
|
||||
"28 13 40 A3 96 DC 49 83 55 E1 85 AB BD 4D ED 88 "
|
||||
"FA 36 69 A9 77 59 5A 9C D0 A0 B1 3D EB 31 16 DC "
|
||||
"3E 29 7B 39 01 5B D4 FF 5C E5 9E DA F7 55 D5 3F "
|
||||
"E3 3B 51 76 83 8E 40 AE E1 2E E8 3E F8 08 B7 B0 "
|
||||
"24 26 91 AD 82 4C 2E 2F 37 7A 34 A1 05 BD 8C 9A "
|
||||
"75 52 5C CD 59 80 CB 92 F8 B1 F8 A5 F2 2C 9F 4A "
|
||||
"59 BF EF 76 A3 74 4F E1 C9 7C 7F 91 D9 0D 12 05 "
|
||||
"B2 8E D0 E0 BB 46 D4 5C 44 2F 65 6D 7A 1C 02 86 "
|
||||
"FB 7E 7D B6 2A 57 B9 DB 80 CD 02 BF E7 9E 35 21 "
|
||||
"FB BE 28 13 82 9F F0 74 F7 92 55 DE F2 7B F2 F2 "
|
||||
"7D F5 A0 14 0F 99 4D 25 F4 DC 11 17 7A 77 65 77 "
|
||||
"CC BE EF 90 88 E8 FD B2 4E 8E F5 26 FE 53 5D 65 "
|
||||
"A9 74 47 0B CB E9 E8 71 95 95 87 6C FD 86 94 A7 "
|
||||
"E5 FC 20 00 1E 0A 0A E3 85 17 24 D4 D0 73 8A 11 "
|
||||
"1E 1E EF 83 E3 D7 E1 BF CC 98 07 6D 70 37 3A 8F "
|
||||
"31 17 55 4E 60 A8 C8 AB 4F 08 2D 37 76 E6 2B 58 "
|
||||
"DD 81 0F D1 6E 9A A6 55 3D 80 82 99 9E 2D 16 9A "
|
||||
"DF 4E CB 3B 5D DA A8 53 08 C7 FF 54 DD C6 11 31 "
|
||||
"1A B6 EB A3 03 08 4A FB B4 45 EC C0 7C 0D C6 CF "
|
||||
"CB 1B 78 46 88 8F F4 6A 15 62 2F 17 12 E6 41 64 "
|
||||
"76 58 96 78 DB 29 B5 6A AE DE 63 41 6F BE 9B 37 "
|
||||
"6C C9 D0 EC 1B F6 79 17 9E FE 79 0E B1 82 28 F2 "
|
||||
"06 15 C2 BE 96 9C E0 81 80 D7 00 DB 95 87 4B C0 "
|
||||
"0D 91 55 5B 1F 86 22 64 74 EA 1B 89 85 D2 DD F7 "
|
||||
"9F F1 D9 09 06 64 FA 6D 59 72 EF CE 66 A7 03 D1 "
|
||||
"99 E8 DF AE D7 63 5F 60 5F AB 6E C5 22 C8 3A 94 "
|
||||
"6A 3B 00 72 F8 DB 90 E7 05 DC A2 89 0F 83 AA 03 "
|
||||
"FE 42 14 1C 8A E6 1C 9E DB D8 D0 CA 97 21 6C AD "
|
||||
"ED 0A E0 A2 9E EC C1 FF D1 B4 8A 9A AD AB 34 0B "
|
||||
"13 3F B5 18 8D 85 9E 0D F9 FB AC 21 2E DD 7A DE "
|
||||
"BF 9F 7E BD BF 84 DF F5 FD 1E BE E1 1F 0F F8 18 "
|
||||
"9D 73 09 02 29 B7 5B 26 7E 44 75 04 4D B1 AA 2F "
|
||||
"3A DB 46 38 12 D1 41 35 91 29 06 DF C9 98 69 92 "
|
||||
"02 F2 48 12 A9 71 D2 AE 3B 23 6D 1C E2 6B 8B 75 "
|
||||
"87 4A 13 A7 1F 81 4D 29 65 53 0A 3A 34 CE 6D E6 "
|
||||
"31 8D 7E 4E DD 25 6E 76 44 82 3C 47 36 4C B9 C4 "
|
||||
"9B F4 4F 84 43 11 56 C2 94 53 7E B0 2E 36 DA EB "
|
||||
"77 5F C1 64 E2 CA 9F BE 29 D8 06 36 53 D0 6F 82 "
|
||||
"19 DA BC 8C 5F 4D 45 E7 21 37 9E 90 A6 D4 33 A8 "
|
||||
"64 4D EC BC 90 5E FE 8E 8B CA 17 7C FF AC 96 BB "
|
||||
"21 CF 3D 24 71 3B C2 A1 74 68 85 CF 32 8E 7F 63 "
|
||||
"39 C5 E7 8E A5 E0 CD 3A F5 9A B8 FD 43 D4 43 39 "
|
||||
"08 8E 45 76 5F DF E9 17 54 59 12 ED D0 E9 3D 6F "
|
||||
"3F 02 14 8A 0A 47 9A D1 E7 FA 4E A1 41 00 50 EF "
|
||||
"60 9D 4D C1 CA 87 98 40 E7 B2 0F 76 C0 9D 71 EF "
|
||||
"D7 46 93 C1 2B 9F 11 B8 F9 05 AC ED A7 72 6B F5 "
|
||||
"11 9B 3E 0A 04 21 7D 06 D7 46 76 7B AD AE 9D 95 "
|
||||
"A6 47 68 05 AD F5 38 7C C7 A5 5A CA B2 CB 48 18 "
|
||||
"C1 F2 62 55 98 36 39 08 80 C5 28 B1 06 E4 FB 46 "
|
||||
"11 3C 38 A1 4F 1C FE A1 81 B7 FC DB 94 B0 7A FE "
|
||||
"B5 74 F1 BB 92 AA FF B0 FE 1E 31 8B C6 BC F0 4F "
|
||||
"1A FE 91 C5 7A 9C 73 09 4A 32 90 51 01 8B 12 C0 "
|
||||
"20 CA 3C CB 14 83 D3 C7 7C 5A 12 79 EE 56 1A 36 "
|
||||
"C4 09 E2 3E DC E8 CE F1 C1 A1 9E 99 DA 64 4F CF "
|
||||
"1E D6 2B 70 27 86 3E CF BE 75 1C 39 9B F9 53 63 "
|
||||
"C1 6B 58 CC 71 D2 07 41 88 BB 14 70 96 F1 68 CE "
|
||||
"13 75 FE F4 A0 C8 85 A2 67 18 49 56 0D 07 94 1D "
|
||||
"74 61 89 0C 32 49 9D 0D 94 73 4A AB 1A E9 0F E0 "
|
||||
"BA B6 4A 34 F9 33 1D B3 71 C2 B8 64 D7 0B CB 19 "
|
||||
"F7 BD E0 69 3E 24 96 B1 C4 28 09 5F 58 AE 8A C0 "
|
||||
"83 99 19 64 4D 44 37 55 A6 9B A1 42 50 84 B8 18 "
|
||||
"29 B5 21 91 58 23 88 EB 8F 13 4A 24 09 EC 0F 6D "
|
||||
"7D AF 3E FC F7 F3 9F 34 39 15 C4 84 03 BB 7E 67 "
|
||||
"39 5F 2A 2C 67 94 F4 A6 B5 02 3F 45 56 79 0C 2A "
|
||||
"9B 25 77 67 C2 3B CC F2 71 3B 4F 83 2A 8D 8C 53 "
|
||||
"0D 18 49 54 CA 58 0E BE 8B 3A 53 74 FC 6F 47 28 "
|
||||
"07 8E C1 F5 53 D3 34 4B 08 05 FF E9 14 29 40 1B "
|
||||
"57 AD 77 EC E8 DA DA 35 55 A7 78 03 56 4C 7C B2 "
|
||||
"ED 3B B5 61 65 91 DF 41 B4 5D C9 B7 9B 13 82 41 "
|
||||
"15 D7 B3 6E 1C C8 15 B4 F0 F3 3F 91 4B A1 C8 90 "
|
||||
"78 91 39 5A 21 55 DA 6A E1 2C BA C9 38 69 F6 AE "
|
||||
"A8 2B 8C B7 14 C1 35 82 35 A0 78 47 56 C0 9A A7 "
|
||||
"7F 74 14 64 85 F1 B7 48 BC 55 8C 6A A4 95 1C CB "
|
||||
"F3 52 F9 54 61 15 27 56 43 D0 27 95 E3 35 AA 39 "
|
||||
"DC 23 38 DA EF 1F 27 65 3A AB F7 CC BB 25 DB 00 "
|
||||
"36 34 96 D1 F7 C4 EC 44 37 42 7E 17 18 67 C8 9C "
|
||||
"9A 5B 39 08 5C 3C F4 92 F1 16 31 88 FA 12 44 9E "
|
||||
"79 27 1C C2 0B 46 AC CD 1F 39 B8 9F 9A 56 34 0A "
|
||||
"85 86 C2 B1 B1 9B 31 CE 47 57 05 3E A7 AE 3F 3E "
|
||||
"01 2D C5 B9 C1 CB BA AB 0A 2A D2 71 E4 EC F8 0A "
|
||||
"71 85 CC A1 CA 6E EF 9D 87 22 38 5D 80 81 F7 1A "
|
||||
"6C 31 7B 82 86 BD 7F 10 9D 89 B6 F7 AF E4 41 0D "
|
||||
"4F 97 28 80 34 06 3E 19 3A 21 60 ED 54 18 02 0F "
|
||||
"2F D5 D5 3B A5 87 01 21 38 1B A6 99 32 28 E9 8D "
|
||||
"6F 02 35 60 85 BD 64 C4 B0 26 7E 68 D1 E6 97 B5 "
|
||||
"32 6E B2 4F EB 06 4C 4D C2 97 8E 6B 30 22 C0 B4 "
|
||||
"3D 47 93 78 67 AC 27 42 DD 5C 3C 27 ED 0A 6C E4 "
|
||||
"4A 0D 0F DF 52 63 A6 70 76 09 F0 2E 58 F6 05 B2 "
|
||||
"DF EE C9 1F CB 1D 11 0C A1 8B 19 26 B8 10 2C 81 "
|
||||
"48 FF 98 EF 30 36 0C 01 C5 4A D9 AC 05 72 89 C7 "
|
||||
"3F D6 4D E0 17 BA BA B3 D3 E8 1B 0C 8C C8 DF 6B "
|
||||
"FE 7E BA 91 FD F6 A0 CB 59 19 B0 01 2F D7 0B A0 "
|
||||
"62 0F 5F CE 74 B8 EB 42 89 B5 BE CA C9 EF DA 9A "
|
||||
"BB C6 66 1B E0 65 EE D4 3A CE D9 CC 0E BB 85 50 "
|
||||
"41 45 01 BA 1B 29 11 6F 34 11 55 03 DD 0C B5 99 "
|
||||
"56 3A 93 4D 4D 95 6D CE C3 51 E0 15 54 3E FF 2F "
|
||||
"A3 DA 59 EC 3D 59 2D 62 FC 64 39 D6 7B C8 80 78 "
|
||||
"1D D7 FD E8 0B 5D 8A ED 1A 9D 98 CB C2 EE 78 47 "
|
||||
"30 AD 8F 64 A5 82 12 23 DA B3 3E CA 4C 85 7A 80 "
|
||||
"D5 9F 46 20 D6 EE D1 F9 33 FA 1F C5 9C 8E F9 1E "
|
||||
"66 51 A5 46 68 DC B7 7F A8 5A DE E6 18 D7 8C 2B "
|
||||
"5D EA A8 EC 6B 8B 48 C1 92 5A C1 B1 6A 5E 37 82 "
|
||||
"22 4B 6A B6 F0 40 16 89 16 A5 81 F8 D4 1B 20 26 "
|
||||
"86 35 E5 AD C1 01 6E C9 B5 D0 69 C5 0B 31 08 51 "
|
||||
"5D 35 FC 74 F5 13 04 7A F4 57 10 53 5B A4 CC 8B "
|
||||
"21 82 82 15 4B 8C 3D 6B DA 91 85 CB D6 CF 05 80 "
|
||||
"D0 F0 CF 0D DF 7A B4 99 C7 F8 D5 4C 76 56 30 E9 "
|
||||
"65 B6 58 60 C1 C0 39 8A 42 54 BC 4A 48 8B A1 D9 "
|
||||
"5C 32 05 7A 1C BB 50 51 5B 7F C7 75 2D 68 55 E6 "
|
||||
"83 7B C3 98 FD E6 D5 B8 DA A8 31 01 78 F5 60 8B "
|
||||
"1A D2 FD 51 34 47 FA AF 23 AE E2 DE 15 A7 07 66 "
|
||||
"69 35 9A 40 61 55 25 98 23 54 2A 50 C9 7D A6 CE "
|
||||
"74 F8 19 0C 8E 63 E5 49 2F F9 17 05 FD 39 15 55 "
|
||||
"F4 B0 91 BF 60 B7 B2 40 2E 7A D3 68 86 C0 FC 38 "
|
||||
"88 AB B9 03 8A 04 05 1A 9F 61 AE F2 D3 B8 A4 29 "
|
||||
"F8 51 43 CF 84 26 4A 90 6E 13 27 AF 7B 52 DB F9 "
|
||||
"00 E8 AE C0 B5 6F 64 03 57 20 59 7C F5 E1 65 A8 "
|
||||
"47 C3 BD EE 72 2A 85 E2 70 8D EA 9D 98 D4 2A D5 "
|
||||
"70 A2 E9 76 A2 DA E6 7C B0 F7 14 D9 23 B6 88 C0 "
|
||||
"B3 6F 42 12 F4 69 0C 15 81 D6 F7 0B B7 1B DF 15 "
|
||||
"E6 75 63 13 53 B3 20 43 79 90 34 E3 34 48 80 D6 "
|
||||
"86 BB 45 A2 85 DD F8 23 64 3B D5 68 AB 99 53 34 "
|
||||
"C6 25 0A 87 73 17 37 56 39 BA 8C 0E 39 24 4B CC "
|
||||
"AA 98 84 0C 2F 27 E6 E2 AC 86 34 5D 1E 25 AE FD "
|
||||
"1E FF 3C 27 AD 26 18 4A 1A E5 09 61 5D 83 5F 2C "
|
||||
"DC 41 A7 C6 07 55 5B B5 0B 71 FE 86 E7 30 A1 BC "
|
||||
"27 AF 5F 24 51 1A DD 20 F6 32 9E 3D 64 6F DC 43 "
|
||||
"65 2A 80 CB 95 C4 B6 F0 E1 F3 CF 6C F2 C2 9C EA "
|
||||
"81 88 0C 2D D2 DA 74 82 C6 A5 1E 98 D3 BC 71 ED "
|
||||
"E2 0B 05 DA BB 0E FA 35 0A 2C D5 C8 62 E7 B1 AF "
|
||||
"95 14 6C 83 7D F1 CE 9F 13 6B D8 68 C9 A5 F5 87 "
|
||||
"2E A5 8F D7 5C B2 C6 99 37 31 5A A4 D0 E2 43 DF "
|
||||
"C8 BE BD 10 C0 D8 22 63 95 46 1E E7 8C A8 61 E4 "
|
||||
"74 02 6C B4 30 F3 06 15 11 E6 2A 3A 0D 3B 2F B9 "
|
||||
"3B B3 83 40 18 79 FB 39 38 B7 CE 4D BA F6 9E AA "
|
||||
"E1 8F 32 1C B1 68 DD 5C 2C 37 65 61 73 3D C6 34 "
|
||||
"56 CD EA BC 77 6A A1 7D 6A F1 F9 78 AF 0F D9 C2 "
|
||||
"AA D3 D7 A8 2D A8 6E BC 19 83 96 B5 A3 3E B3 B2 "
|
||||
"5C 54 AD 77 CE 1D E5 D5 AA B3 0D 36 7A 32 7D 5C "
|
||||
"A3 60 66 8D 84 A0 BD 4F 0F A9 09 89 B8 EC 14 8A "
|
||||
"2B 2B 74 8E 75 77 5A 8E B2 51 D0 26 D6 06 8C 9A "
|
||||
"CA 31 D6 94 17 F0 14 D7 43 1C 82 0C 00 83 E6 75 "
|
||||
"05 5C 52 AB 0C 38 8F A3 35 77 52 E8 3E 3B CB 48 "
|
||||
"81 E3 25 B1 A9 40 12 76 4F 16 F1 CE 3D D7 23 89 "
|
||||
"44 D7 3F 24 7E B7 46 66 C1 16 7A 17 B2 2A 99 F1 "
|
||||
"AC 3C C9 9D C5 FE 89 BE BF 2C 68 BC 2C A7 F1 C5 "
|
||||
"2F 26 1E CC D1 AF 7D AA 7D C5 94 4A 4D C4 87 97 "
|
||||
"2D 2B 6A 5E 5E BF 39 82 18 AB 8C B9 DC 80 83 A1 "
|
||||
"D1 80 D2 65 FE 2E CC 6A F1 02 84 B2 36 60 37 24 "
|
||||
"4E 5E 57 AD A5 C5 50 1A 5E A4 5C 31 B6 93 60 57 "
|
||||
"AC EB ED 65 3F BF EA C7 08 CA 13 00 93 E5 E6 79 "
|
||||
"F6 37 20 CA B4 6E 39 9E 83 4F 15 8B 15 CD E7 8C "
|
||||
"90 93 B0 85 91 9B AE 21 EF 03 D0 A4 B6 2A B4 C6 "
|
||||
"D3 07 04 92 54 72 8E EC 2E B3 47 6C CE 42 06 7F "
|
||||
"E0 5B 96 F2 48 8B FA 8F 83 E2 47 10 A5 B7 30 F8 "
|
||||
"68 B0 FD 02 74 6F 48 71 D7 F1 2E DF A1 52 61 76 "
|
||||
"99 47 BE 0A 2F F8 F2 69 9D AD 03 FA E6 84 A7 CF "
|
||||
"35 7D 8F 5F C5 A6 9B 21 66 35 BC 58 D5 89 B5 E0 "
|
||||
"9F 11 F0 A8 8A 1F C8 3C 24 B2 B7 F1 6C 8A DB 3B "
|
||||
"39 7A CA D0 EF 15 61 22 72 FD FC 02 3D BD 76 35 "
|
||||
"9E E1 C6 D7 2C B2 59 E1 03 E0 FF 7A 87 03 79 9F "
|
||||
"61 AB CC 49 98 C2 41 CF 6E 9B AA 52 9B D0 08 B5 "
|
||||
"9E 23 F6 C1 39 82 77 16 5D D4 E1 B3 AD A0 0C 58 "
|
||||
"F8 E2 67 00 6A 0B 4B D2 6C E1 C5 6B 9D BA 3F 40 "
|
||||
"82 C5 28 B8 C1 60 75 85 EE C4 FA 04 ED 62 64 B6 "
|
||||
"29 10 67 4B 9B D6 6C 0E 06 62 64 83 CA F0 2F 2D "
|
||||
"B8 F6 0A D7 D7 6A 1C 58 14 BE 18 60 80 29 02 CD "
|
||||
"F6 B1 95 A5 6D 2E 27 9C 08 E3 1F C5 C2 07 7F 63 "
|
||||
"7F DB 82 C6 C6 85 AC A6 D2 4C F1 7F DB 1D CF 86 "
|
||||
"20 56 60 C0 24 E0 C0 42 0B 4E 00 5F 8B 78 60 FE "
|
||||
"EA EC 6D 31 93 49 70 EB 2A 45 4F 92 9B 6C 17 28 "
|
||||
"BB 89 FC C0 07 84 CC AD 1B 85 F2 85 18 5C 3D 5A "
|
||||
"60 54 AF 03 9D 9E E4 26 D3 86 AA 0B 7C A3 32 9C "
|
||||
"C2 0F 3A D4 3E 1F 52 43 A8 31 E9 70 FC 0C B4 7C "
|
||||
"F5 E3 C7 6F 11 ED 22 4C 0C 1B 82 CB 72 A4 95 28 "
|
||||
"1A D4 1B E5 C4 6E D7 F1 EC BF 25 2C B8 92 87 A8 "
|
||||
"D2 15 79 34 39 C0 BE 0D C8 68 2D F2 D3 8E 01 09 "
|
||||
"3C 48 94 32 69 89 D5 C0 5D E8 2C E6 A6 97 59 4B "
|
||||
"9A C6 61 B0 9E DB 81 DC D3 F9 47 34 84 00 CA 87 "
|
||||
"BE 5D 6D 56 F3 01 02 3B FF FF FF FF 00 00 00 00"
|
||||
)
|
||||
|
||||
_BIN_KEYS: bytes = bytes.fromhex(_HEX_KEY.replace(" ", ""))
|
||||
|
||||
|
||||
@dataclass
|
||||
class GbbqRecord:
|
||||
"""股本变迁记录。"""
|
||||
|
||||
market: int
|
||||
code: str
|
||||
datetime: int
|
||||
category: int
|
||||
hongli_panqianliutong: float
|
||||
peigujia_qianzongguben: float
|
||||
songgu_qianzongguben: float
|
||||
peigu_houzongguben: float
|
||||
_raw: bytes = field(default=b"", repr=False, compare=False)
|
||||
|
||||
|
||||
def read_gbbq(filepath: str | Path) -> list[GbbqRecord]:
|
||||
"""从本地 gbbq 文件读取股本变迁数据(XOR 加密)。
|
||||
|
||||
Args:
|
||||
filepath: gbbq 文件路径。
|
||||
|
||||
Returns:
|
||||
GbbqRecord 列表。
|
||||
"""
|
||||
filepath = Path(filepath)
|
||||
if not filepath.is_file():
|
||||
raise TdxFileNotFoundError(f"股本变迁文件不存在: {filepath}")
|
||||
|
||||
content = filepath.read_bytes()
|
||||
if len(content) < 4:
|
||||
return []
|
||||
|
||||
(count,) = struct.unpack("<I", content[:4])
|
||||
results: list[GbbqRecord] = []
|
||||
data_offset = 4
|
||||
|
||||
for _ in range(count):
|
||||
clear_data = bytearray()
|
||||
# 每条记录解密 3 轮,每轮 8 字节
|
||||
for _round in range(3):
|
||||
(eax,) = struct.unpack("<I", _BIN_KEYS[0x44 : 0x44 + 4])
|
||||
(ebx,) = struct.unpack("<I", content[data_offset : data_offset + 4])
|
||||
num = (eax ^ ebx) & _MASK32
|
||||
(numold,) = struct.unpack("<I", content[data_offset + 4 : data_offset + 8])
|
||||
|
||||
for j in range(0x40, 0x04 - 1, -4):
|
||||
ebx = (num & 0xFF0000) >> 16
|
||||
(eax,) = struct.unpack("<I", _BIN_KEYS[ebx * 4 + 0x448 : ebx * 4 + 0x448 + 4])
|
||||
ebx = num >> 24
|
||||
(eax_add,) = struct.unpack("<I", _BIN_KEYS[ebx * 4 + 0x48 : ebx * 4 + 0x48 + 4])
|
||||
eax = (eax + eax_add) & _MASK32
|
||||
ebx = (num & 0xFF00) >> 8
|
||||
(eax_xor,) = struct.unpack("<I", _BIN_KEYS[ebx * 4 + 0x848 : ebx * 4 + 0x848 + 4])
|
||||
eax = (eax ^ eax_xor) & _MASK32
|
||||
ebx = num & 0xFF
|
||||
(eax_add,) = struct.unpack("<I", _BIN_KEYS[ebx * 4 + 0xC48 : ebx * 4 + 0xC48 + 4])
|
||||
eax = (eax + eax_add) & _MASK32
|
||||
(eax_xor,) = struct.unpack("<I", _BIN_KEYS[j : j + 4])
|
||||
eax = (eax ^ eax_xor) & _MASK32
|
||||
ebx = num
|
||||
num = (numold ^ eax) & _MASK32
|
||||
numold = ebx
|
||||
|
||||
(numold_op,) = struct.unpack("<I", _BIN_KEYS[:4])
|
||||
numold = (numold ^ numold_op) & _MASK32
|
||||
clear_data.extend(struct.pack("<II", numold, num))
|
||||
data_offset += 8
|
||||
|
||||
# 追加剩余 5 字节
|
||||
clear_data.extend(content[data_offset : data_offset + 5])
|
||||
|
||||
v1, v2, v3, v4, v5, v6, v7, v8 = struct.unpack("<B7sIBffff", clear_data)
|
||||
results.append(
|
||||
GbbqRecord(
|
||||
market=v1,
|
||||
code=v2.rstrip(b"\x00").decode("utf-8"),
|
||||
datetime=v3,
|
||||
category=v4,
|
||||
hongli_panqianliutong=v5,
|
||||
peigujia_qianzongguben=v6,
|
||||
songgu_qianzongguben=v7,
|
||||
peigu_houzongguben=v8,
|
||||
_raw=bytes(clear_data),
|
||||
)
|
||||
)
|
||||
data_offset += 5
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,59 @@
|
||||
"""历史财务数据读取(gpcw*.dat / gpcw*.zip 文件)。"""
|
||||
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from ..codec.financial import parse_financial_dat
|
||||
from ..exceptions import TdxFileNotFoundError, TdxOfflineError
|
||||
from ..models.enums import Market
|
||||
from ..models.finance import FinancialRecord
|
||||
|
||||
|
||||
def read_history_financial(filepath: str | Path) -> list[FinancialRecord]:
|
||||
"""从本地 gpcw*.dat 或 gpcw*.zip 文件读取历史财务数据。
|
||||
|
||||
复用 codec/financial.py 的 parse_financial_dat() 解析二进制格式。
|
||||
|
||||
Args:
|
||||
filepath: .dat 或 .zip 文件路径。
|
||||
|
||||
Returns:
|
||||
FinancialRecord 列表。
|
||||
"""
|
||||
filepath = Path(filepath)
|
||||
if not filepath.is_file():
|
||||
raise TdxFileNotFoundError(f"历史财务数据文件不存在: {filepath}")
|
||||
|
||||
if filepath.suffix.lower() == ".zip":
|
||||
data = _read_from_zip(filepath)
|
||||
else:
|
||||
data = filepath.read_bytes()
|
||||
|
||||
raw_records = parse_financial_dat(data)
|
||||
results: list[FinancialRecord] = []
|
||||
for code, market_byte, report_date, fields in raw_records:
|
||||
try:
|
||||
market = Market(market_byte)
|
||||
except ValueError:
|
||||
market = Market.SZ # 默认深圳
|
||||
results.append(
|
||||
FinancialRecord(
|
||||
code=code,
|
||||
market=market,
|
||||
report_date=report_date,
|
||||
fields=fields,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def _read_from_zip(zip_path: Path) -> bytes:
|
||||
"""从 zip 中提取 .dat 文件内容。"""
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith(".dat"):
|
||||
return zf.read(name)
|
||||
raise TdxOfflineError(f"zip 中未找到 .dat 文件: {zip_path}")
|
||||
except zipfile.BadZipFile as e:
|
||||
raise TdxOfflineError(f"无效的 zip 文件: {zip_path}") from e
|
||||
@@ -0,0 +1,122 @@
|
||||
"""分钟 K 线数据读取(.5 文件和 .lc1/.lc5 文件)。"""
|
||||
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
from ..exceptions import TdxFileNotFoundError
|
||||
from ..models.bar import SecurityBar
|
||||
|
||||
# .5 文件: 日期(2B) 时间(2B) 开盘(4B) 最高(4B) 最低(4B) 收盘(4B) 额(4B) 量(4B) 保留(4B)
|
||||
_MIN_FMT = struct.Struct("<HHIIIIfII")
|
||||
|
||||
# .lc1/.lc5 文件: 日期(2B) 时间(2B) 开(4Bf) 高(4Bf) 低(4Bf) 收(4Bf) 额(4Bf) 量(4B) 保留(4B)
|
||||
_LC_MIN_FMT = struct.Struct("<HHfffffII")
|
||||
|
||||
|
||||
def _decode_tdx_date(num: int) -> tuple[int, int, int]:
|
||||
"""解码通达信压缩日期(2 字节)。"""
|
||||
year = num // 2048 + 2004
|
||||
month = (num % 2048) // 100
|
||||
day = (num % 2048) % 100
|
||||
return year, month, day
|
||||
|
||||
|
||||
def _decode_tdx_time(num: int) -> tuple[int, int]:
|
||||
"""解码通达信分钟时间(从 0:00 开始的分钟数)。"""
|
||||
return num // 60, num % 60
|
||||
|
||||
|
||||
def read_5min_bars(filepath: str | Path) -> list[SecurityBar]:
|
||||
"""从本地 .5 文件读取 5 分钟 K 线数据。
|
||||
|
||||
OHLC 为整数,需除以 100 得到实际价格。
|
||||
|
||||
Args:
|
||||
filepath: .5 文件路径。
|
||||
|
||||
Returns:
|
||||
SecurityBar 列表(按时间升序)。
|
||||
"""
|
||||
filepath = Path(filepath)
|
||||
if not filepath.is_file():
|
||||
raise TdxFileNotFoundError(f"分钟线数据文件不存在: {filepath}")
|
||||
|
||||
data = filepath.read_bytes()
|
||||
if len(data) < _MIN_FMT.size:
|
||||
return []
|
||||
|
||||
results: list[SecurityBar] = []
|
||||
record_size = _MIN_FMT.size
|
||||
for offset in range(0, len(data) - record_size + 1, record_size):
|
||||
raw = data[offset : offset + record_size]
|
||||
date_num, time_num, op, hi, lo, cl, amount, vol, _res = _MIN_FMT.unpack(raw)
|
||||
|
||||
year, month, day = _decode_tdx_date(date_num)
|
||||
hour, minute = _decode_tdx_time(time_num)
|
||||
|
||||
results.append(
|
||||
SecurityBar(
|
||||
open=op / 100.0,
|
||||
close=cl / 100.0,
|
||||
high=hi / 100.0,
|
||||
low=lo / 100.0,
|
||||
vol=vol,
|
||||
amount=amount,
|
||||
year=year,
|
||||
month=month,
|
||||
day=day,
|
||||
hour=hour,
|
||||
minute=minute,
|
||||
_raw=raw,
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def read_lc_min_bars(filepath: str | Path) -> list[SecurityBar]:
|
||||
"""从本地 .lc1/.lc5 文件读取分钟 K 线数据。
|
||||
|
||||
OHLC 为 float 类型,无需额外转换。
|
||||
|
||||
Args:
|
||||
filepath: .lc1 或 .lc5 文件路径。
|
||||
|
||||
Returns:
|
||||
SecurityBar 列表(按时间升序)。
|
||||
"""
|
||||
filepath = Path(filepath)
|
||||
if not filepath.is_file():
|
||||
raise TdxFileNotFoundError(f"分钟线数据文件不存在: {filepath}")
|
||||
|
||||
data = filepath.read_bytes()
|
||||
if len(data) < _LC_MIN_FMT.size:
|
||||
return []
|
||||
|
||||
results: list[SecurityBar] = []
|
||||
record_size = _LC_MIN_FMT.size
|
||||
for offset in range(0, len(data) - record_size + 1, record_size):
|
||||
raw = data[offset : offset + record_size]
|
||||
date_num, time_num, op, hi, lo, cl, amount, vol, _res = _LC_MIN_FMT.unpack(raw)
|
||||
|
||||
year, month, day = _decode_tdx_date(date_num)
|
||||
hour, minute = _decode_tdx_time(time_num)
|
||||
|
||||
results.append(
|
||||
SecurityBar(
|
||||
open=op,
|
||||
close=cl,
|
||||
high=hi,
|
||||
low=lo,
|
||||
vol=vol,
|
||||
amount=amount,
|
||||
year=year,
|
||||
month=month,
|
||||
day=day,
|
||||
hour=hour,
|
||||
minute=minute,
|
||||
_raw=raw,
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,74 @@
|
||||
"""通达信安装目录检测与路径解析。"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ..exceptions import TdxOfflineError
|
||||
|
||||
_WIN_CANDIDATES = [
|
||||
Path(r"C:\new_jyplug"),
|
||||
Path(r"C:\new_tdx"),
|
||||
Path(r"D:\new_jyplug"),
|
||||
Path(r"D:\new_tdx"),
|
||||
]
|
||||
|
||||
_UNIX_CANDIDATES = [
|
||||
Path.home() / "new_jyplug",
|
||||
Path.home() / "new_tdx",
|
||||
]
|
||||
|
||||
|
||||
def detect_tdx_home() -> Path | None:
|
||||
"""按优先级检测通达信安装目录。
|
||||
|
||||
1. TDX_HOME 环境变量
|
||||
2. 平台常见路径猜测
|
||||
"""
|
||||
env = os.getenv("TDX_HOME")
|
||||
if env:
|
||||
p = Path(env)
|
||||
if p.is_dir():
|
||||
return p
|
||||
candidates = _WIN_CANDIDATES if sys.platform == "win32" else _UNIX_CANDIDATES
|
||||
for p in candidates:
|
||||
if p.is_dir():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def resolve_vipdoc(path: str | Path | None = None) -> Path:
|
||||
"""解析 vipdoc 数据目录。
|
||||
|
||||
Args:
|
||||
path: 显式指定的 vipdoc 路径。为 None 时自动检测。
|
||||
|
||||
Returns:
|
||||
vipdoc 目录的 Path 对象。
|
||||
|
||||
Raises:
|
||||
TdxOfflineError: 无法定位 vipdoc 目录。
|
||||
"""
|
||||
if path is not None:
|
||||
p = Path(path)
|
||||
if p.is_dir():
|
||||
return p
|
||||
raise TdxOfflineError(f"指定的 vipdoc 路径不存在: {p}")
|
||||
home = detect_tdx_home()
|
||||
if home is None:
|
||||
raise TdxOfflineError(
|
||||
"无法定位通达信安装目录,请设置 TDX_HOME 环境变量或显式传入 vipdoc 路径"
|
||||
)
|
||||
vipdoc = home / "vipdoc"
|
||||
if not vipdoc.is_dir():
|
||||
raise TdxOfflineError(f"vipdoc 目录不存在: {vipdoc}")
|
||||
return vipdoc
|
||||
|
||||
|
||||
def _market_to_exchange(market: int) -> str:
|
||||
"""Market 枚举值 → vipdoc 子目录名(sh/sz)。"""
|
||||
if market == 0: # Market.SZ
|
||||
return "sz"
|
||||
if market == 1: # Market.SH
|
||||
return "sh"
|
||||
raise TdxOfflineError(f"不支持的市场代码: {market}")
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
from .async_ import AsyncTdxConnection
|
||||
from .sync import TdxConnection
|
||||
|
||||
__all__ = ["TdxConnection", "AsyncTdxConnection"]
|
||||
@@ -0,0 +1,149 @@
|
||||
"""异步 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
|
||||
# 单连接不支持请求复用;所有 IO 在连接内串行执行。
|
||||
self._io_lock = asyncio.Lock()
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""建立 TCP 连接并完成握手。"""
|
||||
async with self._io_lock:
|
||||
if self._writer is not None and not self._writer.is_closing():
|
||||
return
|
||||
await self._connect_unlocked()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭连接。"""
|
||||
async with self._io_lock:
|
||||
await self._close_unlocked()
|
||||
|
||||
async def execute(self, cmd: "BaseCommand[T]") -> T:
|
||||
"""执行一条命令(异步版本)。
|
||||
|
||||
同一连接上的并发调用会在此处串行化,避免 StreamReader 并发读取冲突。
|
||||
"""
|
||||
async with self._io_lock:
|
||||
if self._writer is None or self._reader is None:
|
||||
raise TdxConnectionError("未连接,请先调用 connect()")
|
||||
request = cmd.build_request()
|
||||
try:
|
||||
self._writer.write(request)
|
||||
await asyncio.wait_for(self._writer.drain(), timeout=self.timeout)
|
||||
header_buf = await self._recv_exact(HEADER_SIZE)
|
||||
header = parse_header(header_buf)
|
||||
raw_body = await self._recv_exact(header.zipsize)
|
||||
except asyncio.TimeoutError as e:
|
||||
await self._close_unlocked()
|
||||
raise TdxConnectionError(f"通信超时: {self.timeout}s") from e
|
||||
except (OSError, asyncio.IncompleteReadError) as e:
|
||||
await self._close_unlocked()
|
||||
raise TdxConnectionError(f"通信错误: {e}") from e
|
||||
|
||||
body = decompress_body(header, raw_body)
|
||||
return cmd.parse_response(body)
|
||||
|
||||
async def _connect_unlocked(self) -> None:
|
||||
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
|
||||
try:
|
||||
await self._send_setup()
|
||||
except Exception:
|
||||
await self._close_unlocked()
|
||||
raise
|
||||
|
||||
async def _close_unlocked(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
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 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 asyncio.wait_for(self._writer.drain(), timeout=self.timeout)
|
||||
try:
|
||||
hdr_buf = await self._recv_exact(HEADER_SIZE)
|
||||
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 asyncio.wait_for(
|
||||
self._reader.readexactly(n),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
return data
|
||||
@@ -0,0 +1,250 @@
|
||||
"""同步 TCP 连接(基于 socket)。"""
|
||||
|
||||
import socket
|
||||
import time
|
||||
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
|
||||
|
||||
# 已知可用的通达信行情服务器(按优先级排序)
|
||||
# 原有地址
|
||||
KNOWN_HOSTS: list[str] = [
|
||||
"180.153.18.170",
|
||||
"124.71.187.122",
|
||||
"180.153.18.171",
|
||||
"180.153.18.172",
|
||||
"119.147.212.81",
|
||||
"115.238.56.198",
|
||||
"115.238.90.165",
|
||||
"218.75.126.9",
|
||||
"47.107.75.159",
|
||||
"59.175.238.38",
|
||||
# 来自通达信 connect.cfg [HQHOST](2025-05)
|
||||
"110.41.147.114",
|
||||
"110.41.2.72",
|
||||
"101.33.225.16",
|
||||
"175.178.112.197",
|
||||
"175.178.128.227",
|
||||
"43.139.95.83",
|
||||
"124.223.163.242",
|
||||
"122.51.120.217",
|
||||
"150.158.160.2",
|
||||
"123.60.164.122",
|
||||
"111.229.247.189",
|
||||
"124.70.199.56",
|
||||
"62.234.50.143",
|
||||
"81.70.151.186",
|
||||
"82.156.214.79",
|
||||
"159.75.29.111",
|
||||
"43.139.18.171",
|
||||
"81.71.32.47",
|
||||
"122.51.232.182",
|
||||
"118.25.98.114",
|
||||
"121.36.225.169",
|
||||
"123.60.70.228",
|
||||
"123.60.73.44",
|
||||
"124.70.133.119",
|
||||
"124.71.187.72",
|
||||
"119.97.185.59",
|
||||
"129.204.230.128",
|
||||
"101.42.240.54",
|
||||
"124.71.9.153",
|
||||
"123.60.84.66",
|
||||
"111.230.186.52",
|
||||
"101.43.159.194",
|
||||
"120.53.8.251",
|
||||
"152.136.191.169",
|
||||
"116.205.163.254",
|
||||
"116.205.171.132",
|
||||
"116.205.183.150",
|
||||
"49.232.15.141",
|
||||
"82.156.174.84",
|
||||
"101.42.164.241",
|
||||
"101.35.121.35",
|
||||
"111.231.113.208",
|
||||
]
|
||||
|
||||
# 计算服务器(用于下载 tdxfin/ 财务数据)
|
||||
CALC_HOSTS: list[str] = [
|
||||
"120.76.152.87",
|
||||
]
|
||||
|
||||
|
||||
def ping_host(
|
||||
host: str,
|
||||
port: int = _DEFAULT_PORT,
|
||||
timeout: float = 5.0,
|
||||
) -> float | None:
|
||||
"""测量连接到指定服务器并完成握手所需的时间(秒)。
|
||||
|
||||
返回延迟(秒),连接失败时返回 None。
|
||||
"""
|
||||
t0 = time.monotonic()
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout)
|
||||
try:
|
||||
sock.connect((host, port))
|
||||
# 发送第一条握手命令并等待响应作为可用性验证
|
||||
sock.sendall(SETUP_COMMANDS[0])
|
||||
hdr_buf = _recv_exact_sock(sock, HEADER_SIZE)
|
||||
hdr = parse_header(hdr_buf)
|
||||
if hdr.zipsize > 0:
|
||||
_recv_exact_sock(sock, hdr.zipsize)
|
||||
return time.monotonic() - t0
|
||||
except OSError:
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def ping_all(
|
||||
hosts: list[str] = KNOWN_HOSTS,
|
||||
port: int = _DEFAULT_PORT,
|
||||
timeout: float = 5.0,
|
||||
) -> list[tuple[str, float]]:
|
||||
"""并发测量多台服务器延迟,返回按延迟排序的 (host, latency_seconds) 列表。
|
||||
|
||||
不可达的服务器不包含在结果中。
|
||||
"""
|
||||
import concurrent.futures
|
||||
|
||||
results: list[tuple[str, float]] = []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=len(hosts)) as pool:
|
||||
futures = {pool.submit(ping_host, h, port, timeout): h for h in hosts}
|
||||
for fut in concurrent.futures.as_completed(futures):
|
||||
host = futures[fut]
|
||||
latency = fut.result()
|
||||
if latency is not None:
|
||||
results.append((host, latency))
|
||||
results.sort(key=lambda t: t[1])
|
||||
return results
|
||||
|
||||
|
||||
def _recv_exact_sock(sock: socket.socket, n: int) -> bytes:
|
||||
buf = bytearray()
|
||||
while len(buf) < n:
|
||||
chunk = sock.recv(n - len(buf))
|
||||
if not chunk:
|
||||
raise TdxConnectionError("连接被服务器关闭")
|
||||
buf.extend(chunk)
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
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
|
||||
try:
|
||||
self._send_setup()
|
||||
except Exception:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._sock = None
|
||||
raise
|
||||
|
||||
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
|
||||
return _recv_exact_sock(self._sock, n)
|
||||
Reference in New Issue
Block a user