mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 13:24:18 +08:00
feat: 离线 fixture 测试、unknown 字段探测脚本、高可用传输层
- tests/fixtures/:录制 12 条命令真实响应 body(hex),配套 json 预期值 - tests/unit/test_commands_offline.py:13 个离线 pytest,无需网络,验证 各命令解析正确性及所有已知 bug 修复(#1~#5) - scripts/probe_unknowns.py:对比 unknown_1/2/3/5~8 与均价、涨停价等假设, 输出相关性分析报告,供后续字段逆向使用 - transport/sync.py:新增 ping_host()、ping_all()(并发测速)、KNOWN_HOSTS - client.py:TdxClient.from_best_host() 工厂方法(自动优选最低延迟服务器)、 TdxClient.ping_all() 静态代理、_execute() 断线自动重连(重试一次) - 修复 GetTransactionDataCmd.parse_response 多余 skip 参数调用 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
283682f6b4
commit
0fa685dbdd
@@ -0,0 +1,189 @@
|
||||
"""未知字段探测脚本:通过批量拉取多只股票数据,尝试推断各 unknown_N 字段的含义。
|
||||
|
||||
用法:
|
||||
cd /home/m/xmtdx
|
||||
python3 scripts/probe_unknowns.py
|
||||
|
||||
输出:
|
||||
1. MinuteBar.unknown_1 vs 分钟均价(累计成交额 / 累计成交量)
|
||||
2. SecurityQuote.unknown_2/3/5/6/7/8 与已知行情指标的相关关系
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import pathlib
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "src"))
|
||||
|
||||
from xmtdx import TdxClient, Market, KlineCategory
|
||||
|
||||
HOST = "180.153.18.170"
|
||||
|
||||
# 沪深各取若干活跃股票
|
||||
SH_CODES = ["600000", "600036", "601318", "600519", "601628"]
|
||||
SZ_CODES = ["000001", "000002", "000858", "002415", "300750"]
|
||||
|
||||
SEP = "-" * 72
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Part 1: MinuteBar.unknown_1 — 是否为分钟均价?
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def probe_minute_unknown_1(c: TdxClient) -> None:
|
||||
print(SEP)
|
||||
print("Part 1: MinuteBar.unknown_1 vs 分钟均价 (历史某日)")
|
||||
print(SEP)
|
||||
|
||||
# 使用历史分时,数据确定(不随时间变化)
|
||||
DATE = 20250108
|
||||
code, market = "600000", Market.SH
|
||||
|
||||
bars = c.get_history_minute_time_data(market, code, DATE)
|
||||
print(f" {market.name} {code} 日期={DATE} 共 {len(bars)} 条分时\n")
|
||||
|
||||
# 同时拉取当日日线 K 作为参考(含 amount/vol 可算均价)
|
||||
# 分时数据无直接成交额,需要用 price × vol 近似
|
||||
# 若 unknown_1 == round(price × 100) 则为原始价格单位均价
|
||||
print(f" {'分钟':>6} {'price':>8} {'vol':>8} {'unknown_1':>12} {'price*100':>10} {'diff':>8}")
|
||||
print(f" {'':-<6} {'':-<8} {'':-<8} {'':-<12} {'':-<10} {'':-<8}")
|
||||
|
||||
exact_match = 0
|
||||
close_match = 0
|
||||
|
||||
for i, b in enumerate(bars[:30]): # 只打印前30条
|
||||
price_x100 = round(b.price * 100)
|
||||
diff = b.unknown_1 - price_x100
|
||||
exact = b.unknown_1 == price_x100
|
||||
close = abs(diff) <= 2
|
||||
|
||||
if exact:
|
||||
exact_match += 1
|
||||
if close:
|
||||
close_match += 1
|
||||
|
||||
flag = " <<< exact" if exact else (" ≈" if close else "")
|
||||
print(f" {i+1:>6} {b.price:>8.2f} {b.vol:>8} {b.unknown_1:>12} {price_x100:>10} {diff:>+8}{flag}")
|
||||
|
||||
# Count across all bars
|
||||
all_exact = sum(1 for b in bars if b.unknown_1 == round(b.price * 100))
|
||||
all_close = sum(1 for b in bars if abs(b.unknown_1 - round(b.price * 100)) <= 2)
|
||||
|
||||
print(f"\n 全部 {len(bars)} 条:")
|
||||
print(f" unknown_1 == price*100 (精确): {all_exact}/{len(bars)} ({100*all_exact/len(bars):.1f}%)")
|
||||
print(f" unknown_1 ≈ price*100 (±2): {all_close}/{len(bars)} ({100*all_close/len(bars):.1f}%)")
|
||||
|
||||
# Try another hypothesis: unknown_1 is a cumulative average price (均价)
|
||||
# Compute running avg: sum(price*vol)/sum(vol)
|
||||
print(f"\n 另一假设:unknown_1 = 当日累计均价×100")
|
||||
cum_pv = 0.0
|
||||
cum_v = 0
|
||||
correct_avg = 0
|
||||
for b in bars:
|
||||
cum_pv += b.price * b.vol
|
||||
cum_v += b.vol
|
||||
if cum_v > 0:
|
||||
avg = cum_pv / cum_v
|
||||
expected = round(avg * 100)
|
||||
if abs(b.unknown_1 - expected) <= 2:
|
||||
correct_avg += 1
|
||||
|
||||
print(f" unknown_1 ≈ 累计均价×100 (±2): {correct_avg}/{len(bars)} ({100*correct_avg/len(bars):.1f}%)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Part 2: SecurityQuote.unknown_N fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def probe_quote_unknowns(c: TdxClient) -> None:
|
||||
print(f"\n{SEP}")
|
||||
print("Part 2: SecurityQuote.unknown_2/3/5/6/7/8 — 与已知字段的关系")
|
||||
print(SEP)
|
||||
|
||||
pairs = [(Market.SH, code) for code in SH_CODES] + [(Market.SZ, code) for code in SZ_CODES]
|
||||
quotes = c.get_security_quotes(pairs)
|
||||
|
||||
print(f" {'market':>6} {'code':>8} {'pre_close':>10} {'price':>8} "
|
||||
f"{'u2':>6} {'u3':>8} {'u5':>6} {'u6':>6} {'u7':>6} {'u8':>6} {'rise_spd':>10}")
|
||||
print(f" {'':-<6} {'':-<8} {'':-<10} {'':-<8} "
|
||||
f"{'':-<6} {'':-<8} {'':-<6} {'':-<6} {'':-<6} {'':-<6} {'':-<10}")
|
||||
|
||||
for q in quotes:
|
||||
pct = (q.price - q.pre_close) / q.pre_close * 100 if q.pre_close else 0
|
||||
print(
|
||||
f" {q.market.name:>6} {q.code:>8} {q.pre_close:>10.2f} {q.price:>8.2f} "
|
||||
f"{q.unknown_2:>6} {q.unknown_3:>8} {q.unknown_5:>6} "
|
||||
f"{q.unknown_6:>6} {q.unknown_7:>6} {q.unknown_8:>6} {q.rise_speed:>10.4f}"
|
||||
)
|
||||
|
||||
print(f"\n 注:rise_speed = reversed_bytes9/100(已确认 = 涨速)")
|
||||
|
||||
# Hypothesis: unknown_3 might relate to 涨停/跌停 price
|
||||
# 涨停 = pre_close * 1.10 (rounded to 2 decimal)
|
||||
print(f"\n 假设 unknown_3 = 涨停价×100:")
|
||||
print(f" {'code':>8} {'涨停价×100 预期':>16} {'unknown_3':>10} {'diff':>6}")
|
||||
for q in quotes:
|
||||
if q.pre_close > 0:
|
||||
limit_up = round(q.pre_close * 1.10 * 100)
|
||||
diff = q.unknown_3 - limit_up
|
||||
print(f" {q.code:>8} {limit_up:>16} {q.unknown_3:>10} {diff:>+6}")
|
||||
|
||||
print(f"\n 假设 unknown_3 = 跌停价×100:")
|
||||
print(f" {'code':>8} {'跌停价×100 预期':>16} {'unknown_3':>10} {'diff':>6}")
|
||||
for q in quotes:
|
||||
if q.pre_close > 0:
|
||||
limit_dn = round(q.pre_close * 0.90 * 100)
|
||||
diff = q.unknown_3 - limit_dn
|
||||
print(f" {q.code:>8} {limit_dn:>16} {q.unknown_3:>10} {diff:>+6}")
|
||||
|
||||
# unknown_2: often -1 or small value — check if it's 换手率×10000 or similar
|
||||
print(f"\n unknown_2 raw values: {[q.unknown_2 for q in quotes]}")
|
||||
print(f" unknown_5 raw values: {[q.unknown_5 for q in quotes]}")
|
||||
print(f" unknown_6 raw values: {[q.unknown_6 for q in quotes]}")
|
||||
print(f" unknown_7 raw values: {[q.unknown_7 for q in quotes]}")
|
||||
print(f" unknown_8 raw values: {[q.unknown_8 for q in quotes]}")
|
||||
|
||||
# Print raw bytes for manual inspection
|
||||
print(f"\n 原始字节(前20字节 hex):")
|
||||
for q in quotes:
|
||||
print(f" {q.code}: {q._raw[:20].hex()}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Part 3: TransactionRecord.unknown_last — 是否为秒数?
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def probe_transaction_unknown_last(c: TdxClient) -> None:
|
||||
print(f"\n{SEP}")
|
||||
print("Part 3: TransactionRecord.unknown_last — 是否为秒或序号?")
|
||||
print(SEP)
|
||||
|
||||
recs = c.get_history_transaction_data(Market.SH, "600000", 20250108, 0, 30)
|
||||
print(f" {'序号':>4} {'时间':>6} {'price':>8} {'vol':>6} {'buy':>4} {'unknown_last':>14}")
|
||||
print(f" {'':-<4} {'':-<6} {'':-<8} {'':-<6} {'':-<4} {'':-<14}")
|
||||
|
||||
for i, r in enumerate(recs):
|
||||
print(f" {i+1:>4} {r.hour:02d}:{r.minute:02d} {r.price:>8.2f} {r.vol:>6} {r.buyorsell:>4} {r.unknown_last:>14}")
|
||||
|
||||
unique = len({r.unknown_last for r in recs})
|
||||
print(f"\n unknown_last 唯一值数量: {unique}/{len(recs)}")
|
||||
print(f" 值分布: {sorted({r.unknown_last for r in recs})}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
print(f"连接 {HOST}:7709 ...")
|
||||
with TdxClient(HOST) as c:
|
||||
probe_minute_unknown_1(c)
|
||||
probe_quote_unknowns(c)
|
||||
probe_transaction_unknown_last(c)
|
||||
|
||||
print(f"\n{SEP}")
|
||||
print("探测完成。根据以上输出可判断各字段含义,更新 models/ 文档注释。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -22,6 +22,7 @@ asyncio 版本::
|
||||
|
||||
from .client import AsyncTdxClient, TdxClient
|
||||
from .exceptions import TdxCommandError, TdxConnectionError, TdxDecodeError, TdxError
|
||||
from .transport.sync import KNOWN_HOSTS, ping_all
|
||||
from .models import (
|
||||
XDXR_CATEGORY_NAMES,
|
||||
CompanyInfoCategory,
|
||||
@@ -58,6 +59,9 @@ __all__ = [
|
||||
"TdxConnectionError",
|
||||
"TdxDecodeError",
|
||||
"TdxCommandError",
|
||||
# 工具
|
||||
"ping_all",
|
||||
"KNOWN_HOSTS",
|
||||
]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
+82
-18
@@ -1,7 +1,9 @@
|
||||
"""高层行情 API:TdxClient(同步)和 AsyncTdxClient(asyncio)。"""
|
||||
|
||||
from types import TracebackType
|
||||
from typing import TypeVar
|
||||
|
||||
from .commands.base import BaseCommand
|
||||
from .commands.company_info import GetCompanyInfoCategoryCmd, GetCompanyInfoContentCmd
|
||||
from .commands.finance_info import GetFinanceInfoCmd
|
||||
from .commands.minute_time import GetHistoryMinuteTimeDataCmd, GetMinuteTimeDataCmd
|
||||
@@ -11,6 +13,7 @@ from .commands.security_list import GetSecurityListCmd
|
||||
from .commands.security_quotes import GetSecurityQuotesCmd
|
||||
from .commands.transaction import GetHistoryTransactionDataCmd, GetTransactionDataCmd
|
||||
from .commands.xdxr_info import GetXdxrInfoCmd
|
||||
from .exceptions import TdxConnectionError
|
||||
from .models.bar import SecurityBar
|
||||
from .models.enums import KlineCategory, Market
|
||||
from .models.finance import CompanyInfoCategory, FinanceInfo, XdxrRecord
|
||||
@@ -18,10 +21,10 @@ from .models.quote import SecurityQuote
|
||||
from .models.security import SecurityInfo
|
||||
from .models.timeseries import MinuteBar, TransactionRecord
|
||||
from .transport.async_ import AsyncTdxConnection
|
||||
from .transport.sync import TdxConnection
|
||||
from .transport.sync import KNOWN_HOSTS, TdxConnection, ping_all
|
||||
|
||||
_DEFAULT_HOST = "180.153.18.170"
|
||||
_DEFAULT_PORT = 7709
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -30,22 +33,66 @@ _DEFAULT_PORT = 7709
|
||||
|
||||
|
||||
class TdxClient:
|
||||
"""同步通达信行情客户端。
|
||||
"""同步通达信行情客户端,支持 IP 优选与断线自动重连。
|
||||
|
||||
使用示例::
|
||||
|
||||
# 单台服务器
|
||||
with TdxClient("180.153.18.170") as c:
|
||||
bars = c.get_security_bars(Market.SH, "600000", KlineCategory.DAY, 0, 100)
|
||||
|
||||
# 自动从候选列表中选延迟最低的服务器
|
||||
with TdxClient.from_best_host() as c:
|
||||
count = c.get_security_count(Market.SH)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = _DEFAULT_HOST,
|
||||
host: str = KNOWN_HOSTS[0],
|
||||
port: int = _DEFAULT_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 = TdxConnection(host, port, timeout)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 工厂方法:自动优选最低延迟服务器
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@classmethod
|
||||
def from_best_host(
|
||||
cls,
|
||||
hosts: list[str] = KNOWN_HOSTS,
|
||||
port: int = _DEFAULT_PORT,
|
||||
timeout: float = 15.0,
|
||||
ping_timeout: float = 5.0,
|
||||
auto_reconnect: bool = True,
|
||||
) -> "TdxClient":
|
||||
"""测量 hosts 中所有服务器延迟,选最低延迟的建立连接。
|
||||
|
||||
若所有服务器均不可达,回退到 hosts[0]。
|
||||
"""
|
||||
ranked = ping_all(hosts, port, ping_timeout)
|
||||
best = ranked[0][0] if ranked else hosts[0]
|
||||
return cls(best, port, timeout, auto_reconnect)
|
||||
|
||||
@staticmethod
|
||||
def ping_all(
|
||||
hosts: list[str] = KNOWN_HOSTS,
|
||||
port: int = _DEFAULT_PORT,
|
||||
timeout: float = 5.0,
|
||||
) -> list[tuple[str, float]]:
|
||||
"""测量多台服务器延迟,返回按延迟排序的 (host, seconds) 列表。"""
|
||||
return ping_all(hosts, port, timeout)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 连接管理
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def connect(self) -> None:
|
||||
self._conn.connect()
|
||||
|
||||
@@ -64,23 +111,40 @@ class TdxClient:
|
||||
) -> None:
|
||||
self.close()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 内部执行:含自动重连
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _execute(self, cmd: "BaseCommand[_T]") -> _T:
|
||||
"""执行命令;断线时尝试重连一次再重试(若 auto_reconnect=True)。"""
|
||||
try:
|
||||
return self._conn.execute(cmd)
|
||||
except TdxConnectionError:
|
||||
if not self._auto_reconnect:
|
||||
raise
|
||||
# 重连后重试一次
|
||||
self._conn.close()
|
||||
self._conn = TdxConnection(self._host, self._port, self._timeout)
|
||||
self._conn.connect()
|
||||
return self._conn.execute(cmd)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 市场信息
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def get_security_count(self, market: Market) -> int:
|
||||
"""获取市场证券总数。"""
|
||||
return self._conn.execute(GetSecurityCountCmd(market))
|
||||
return self._execute(GetSecurityCountCmd(market))
|
||||
|
||||
def get_security_list(self, market: Market, start: int) -> list[SecurityInfo]:
|
||||
"""获取证券列表(每页约1000条,按 start 分页)。"""
|
||||
return self._conn.execute(GetSecurityListCmd(market, start))
|
||||
return self._execute(GetSecurityListCmd(market, start))
|
||||
|
||||
def get_security_quotes(
|
||||
self, stocks: list[tuple[Market, str]]
|
||||
) -> list[SecurityQuote]:
|
||||
"""批量获取实时五档行情(最多80只/次)。"""
|
||||
return self._conn.execute(GetSecurityQuotesCmd(stocks))
|
||||
return self._execute(GetSecurityQuotesCmd(stocks))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# K 线
|
||||
@@ -95,7 +159,7 @@ class TdxClient:
|
||||
count: int = 800,
|
||||
) -> list[SecurityBar]:
|
||||
"""获取 K 线数据(最多800条/次,按 start 分页)。"""
|
||||
return self._conn.execute(GetSecurityBarsCmd(market, code, category, start, count))
|
||||
return self._execute(GetSecurityBarsCmd(market, code, category, start, count))
|
||||
|
||||
def get_index_bars(
|
||||
self,
|
||||
@@ -106,7 +170,7 @@ class TdxClient:
|
||||
count: int = 800,
|
||||
) -> list[SecurityBar]:
|
||||
"""获取指数 K 线数据。"""
|
||||
return self._conn.execute(GetIndexBarsCmd(market, code, category, start, count))
|
||||
return self._execute(GetIndexBarsCmd(market, code, category, start, count))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 分时
|
||||
@@ -114,13 +178,13 @@ class TdxClient:
|
||||
|
||||
def get_minute_time_data(self, market: Market, code: str) -> list[MinuteBar]:
|
||||
"""获取今日分时数据(240条)。"""
|
||||
return self._conn.execute(GetMinuteTimeDataCmd(market, code))
|
||||
return self._execute(GetMinuteTimeDataCmd(market, code))
|
||||
|
||||
def get_history_minute_time_data(
|
||||
self, market: Market, code: str, date: int
|
||||
) -> list[MinuteBar]:
|
||||
"""获取历史某日分时数据(date: YYYYMMDD)。"""
|
||||
return self._conn.execute(GetHistoryMinuteTimeDataCmd(market, code, date))
|
||||
return self._execute(GetHistoryMinuteTimeDataCmd(market, code, date))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 逐笔成交
|
||||
@@ -130,13 +194,13 @@ class TdxClient:
|
||||
self, market: Market, code: str, start: int, count: int = 800
|
||||
) -> list[TransactionRecord]:
|
||||
"""获取当日逐笔成交(分页)。"""
|
||||
return self._conn.execute(GetTransactionDataCmd(market, code, start, count))
|
||||
return self._execute(GetTransactionDataCmd(market, code, start, count))
|
||||
|
||||
def get_history_transaction_data(
|
||||
self, market: Market, code: str, date: int, start: int, count: int = 800
|
||||
) -> list[TransactionRecord]:
|
||||
"""获取历史逐笔成交(date: YYYYMMDD,分页)。"""
|
||||
return self._conn.execute(
|
||||
return self._execute(
|
||||
GetHistoryTransactionDataCmd(market, code, date, start, count)
|
||||
)
|
||||
|
||||
@@ -146,23 +210,23 @@ class TdxClient:
|
||||
|
||||
def get_xdxr_info(self, market: Market, code: str) -> list[XdxrRecord]:
|
||||
"""获取除权除息历史记录。"""
|
||||
return self._conn.execute(GetXdxrInfoCmd(market, code))
|
||||
return self._execute(GetXdxrInfoCmd(market, code))
|
||||
|
||||
def get_finance_info(self, market: Market, code: str) -> FinanceInfo:
|
||||
"""获取最新财务数据。"""
|
||||
return self._conn.execute(GetFinanceInfoCmd(market, code))
|
||||
return self._execute(GetFinanceInfoCmd(market, code))
|
||||
|
||||
def get_company_info_category(
|
||||
self, market: Market, code: str
|
||||
) -> list[CompanyInfoCategory]:
|
||||
"""获取公司信息文件目录。"""
|
||||
return self._conn.execute(GetCompanyInfoCategoryCmd(market, code))
|
||||
return self._execute(GetCompanyInfoCategoryCmd(market, code))
|
||||
|
||||
def get_company_info_content(
|
||||
self, market: Market, code: str, filename: str, offset: int, length: int
|
||||
) -> str:
|
||||
"""读取公司信息文本。"""
|
||||
return self._conn.execute(
|
||||
return self._execute(
|
||||
GetCompanyInfoContentCmd(market, code, filename, offset, length)
|
||||
)
|
||||
|
||||
@@ -183,7 +247,7 @@ class AsyncTdxClient:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = _DEFAULT_HOST,
|
||||
host: str = KNOWN_HOSTS[0],
|
||||
port: int = _DEFAULT_PORT,
|
||||
timeout: float = 15.0,
|
||||
) -> None:
|
||||
|
||||
@@ -28,7 +28,7 @@ class GetTransactionDataCmd(BaseCommand[list[TransactionRecord]]):
|
||||
|
||||
|
||||
def parse_response(self, body: bytes) -> list[TransactionRecord]:
|
||||
return _parse_transaction_body(body, skip=2)
|
||||
return _parse_transaction_body(body)
|
||||
|
||||
|
||||
class GetHistoryTransactionDataCmd(BaseCommand[list[TransactionRecord]]):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""同步 TCP 连接(基于 socket)。"""
|
||||
|
||||
import socket
|
||||
import time
|
||||
from types import TracebackType
|
||||
from typing import TYPE_CHECKING, TypeVar
|
||||
|
||||
@@ -17,6 +18,81 @@ _DEFAULT_HOST = "180.153.18.170"
|
||||
_DEFAULT_PORT = 7709
|
||||
_DEFAULT_TIMEOUT = 15.0
|
||||
|
||||
# 已知可用的通达信行情服务器(按优先级排序)
|
||||
KNOWN_HOSTS: list[str] = [
|
||||
"180.153.18.170",
|
||||
"180.153.18.171",
|
||||
"180.153.18.172",
|
||||
"115.238.56.198",
|
||||
"115.238.90.165",
|
||||
"218.75.126.9",
|
||||
"47.107.75.159",
|
||||
"59.175.238.38",
|
||||
]
|
||||
|
||||
|
||||
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 连接。
|
||||
@@ -112,10 +188,4 @@ class TdxConnection:
|
||||
def _recv_exact(self, n: int) -> bytes:
|
||||
"""循环 recv 直到读满 n 字节。"""
|
||||
assert self._sock is not None
|
||||
buf = bytearray()
|
||||
while len(buf) < n:
|
||||
chunk = self._sock.recv(n - len(buf))
|
||||
if not chunk:
|
||||
raise TdxConnectionError("连接被服务器关闭")
|
||||
buf.extend(chunk)
|
||||
return bytes(buf)
|
||||
return _recv_exact_sock(self._sock, n)
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
1000d7eed0c2cce1cabe00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003630303030302e7478740000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a22c0000b9abcbbeb8c5bff600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003630303030302e74787400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a22c0000ec510000b2c6cef1b7d6cef600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003630303030302e747874000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008e7e00003fd70000b9c9b6abd1d0bebf00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003630303030302e74787400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cd55010061580000b9c9b1bebde1b9b900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003630303030302e747874000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002eae01002f270000d7cab1bed4cbd7f700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003630303030302e747874000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005dd501005d360000d2b5c4dab5e3c6c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003630303030302e7478740000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004cba0b020037210100d0d0d2b5b7d6cef6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004b3630303030302e747874000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a3f12c0300c1240000b9abcbbeb4f3cac200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a13630303030302e747874000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000edb251030065430600d1d0bebfb1a8b8e600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e43630303030302e747874000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ce179509009e1a0100beadd3aab7d6cef600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bb3630303030302e74787400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b5af0a0029790000d6f7c1a6d7b7d7d9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006d3630303030302e7478740000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007ede280b0031680000b7d6baecc0a9b9c900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000273630303030302e7478740000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e0f910b00edea0400b8dfb2e3d6cec0ed00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000013630303030302e74787400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000081fc7b1000b16d0000c1fabba2b0f1b5a5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e3630303030302e74787400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000016c41d1100b51e0000b9d8c1aab8f6b9c900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000473630303030302e747874000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cfade9100017340000
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"num_records": 16,
|
||||
"first": {
|
||||
"filename": "600000.txt",
|
||||
"start": 0,
|
||||
"length": 11426
|
||||
}
|
||||
}
|
||||
+1
File diff suppressed because one or more lines are too long
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"length": 8070,
|
||||
"prefix": "☆最新提示☆ ◇600000 浦发银行 更新日期:2026-04-10◇ 港澳资讯 灵通V9.0\r\n"
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
0100013630303030305f484b4a10000100eb253501460a31015f484b4a00c0f5444aa42c4c86d6224d00000000000000005c8fc23fcf3a1650000000009138814ce8a30c4b400b1d4800000000c05ee54a643b004d79c4424eaee7254d948fe54c00000000907f4c4cf4f4934b6b36b34d48e26e4c000000000c9b4b4ca247404cbacc3e4c44566f4d3d0ab14100004041
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"liutong_guben": 33305837500.0,
|
||||
"zong_guben": 33305837500.0,
|
||||
"meigujing_zichan": 22.1299991607666
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
f000ec5124418510ee03bc9c014123a9344101805000008744010d8032000bae3802189b2c42039a21010fad3a038101bc68012f844f4117ad3c000d9826011a8f3f010fa8204106a312000a9d1f022ab5580012b4294404ac4e42489f490041b90e0043b11c004286180201ac120001b013424384240141b3370101a80f414096110202a4144101a60d0204ab164103901d01028b0d000b90440002b10b03108d3400059c114209a62f4204a53b0001a50c0001be1100019a0f0104b9210105891f0008a632000386124103981f0005ba3700029d1b0001b9080206a120410381150004941d4202923500019e254241a71a00418f0f4143b81c0301ad1f4242a92001408d124141841b0140a5140040982b4141ba0b0041851b4141ba0e0042b21d0042bb134143841e0042af130042950f0041850b004194090141b108414281104149a13d0043a5170043b0140048b5400041b50701419206414185070141bc090041ab074453b357014485170141ac094244ad13414e933d414c832e00478b1b0156b36901438a10424ab32d0247ad2d41499b3241419c060142b20a414587170143961341468a1b024f93694148a03241479b24034296120242962000419c150043a13941409e054243bf184148933700438b120241be0c4244a91d0042831100419c0a41449b190444bc4242449c210042b8150341a7244243b2214243961b0142ab160042b5150141b3070041bb100141b2100040bd040042b51e4142ab16024186114141910b0041b30e414194090041930c0041ac080141b9164142971a0042b61b0240b80c00409d0e0040ba0a004198270140b00400408f104141be15014099090101866341409a104143b4810141429f3201408c104141a7140140be110041951601409a0c4241be1501409d1401408a170040a9064140800e0041b5200404b4544200a41801018f3000019d110000a40a0000a10e0001b710410082170101b32d0000b2094240811b01018d400000b7190100a110010283214100b50e0100a20a00019a0f4100840c0202a61c000287204200a50f01019c130202bd1a0104902e41049a310002a01f4101bd170202871c41049f340002af200002ae1f00019e0e0001b8144101b81401018e104101bf130001ab130001a50a0001b1150003b633000191150001b1194101be274101971f4240a41f004098260040a61641419d2441459d7b014181140140aa130100971200009b0c0000ac160203866c4100a0110101ab1a41019a244100a51c0200810e4100b20e00008c0e4100ac1b4140be1b010094230000980e0000851d0000ac1f000084164140961e0000170000000202a07b
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"num_records": 240,
|
||||
"first": {
|
||||
"price": 10.29,
|
||||
"vol": 10044
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
0a00ec512441800386103200008003411301008003009d0401008003003001008003009d02010080034183020100800301b50202008003411e0100810300170100840302a07b0200
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"num_records": 10,
|
||||
"first": {
|
||||
"hour": 14,
|
||||
"minute": 56,
|
||||
"price": 10.3,
|
||||
"vol": 50
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
f0000000013630303030303e109d0f04040643acfaa60edd0f9889349483013a6ec94da7831cb2851841a6e2020001bd0788124102a8cd0187314203906aab11560fa00f9f01ae920144d203a9a30201f8019bf6010201be2d0009a346410cbd7e020ba33a4104bf37000593210204b51e000f8d400005aa150008b02d4107843a4101821101049a240005ba32410186150103bb1e0101b50b420598580001833e0203b53e0003881a0001af060002ac0e000c8c5700059c3d4101b20c0101b10a41029c280102a31f0002861c00008d054102962000018215010187190001b0260001af11000193164101a7264100a5200000b9320040a12c4140a60c0040bf0801409014424a95e0010141bd1b0042ba330141b41c4140ae030141a7150040a10e41428d320041b71a4141ae0b0141aa09004198140043bc434141bc1b0141841900418c0e0041ba164141a9130141bd160041ad1c00438c360041a40c414189124147a37d0142a01d00419a1c014094070040a8044140a70a00408c0700409f060140ab0841408d0b0040b4120140850b0040ac130040a7050040a60b4140950a0140990441408b0a0140b3094140800d01409a064140bc090141be430040b1110041be1f00408c07004081160040810d01008d05414082200042b13200408e0500409f080040960b4140bf070042843b00409c040140ba090040bd050040af110040ae0400408907004098050040b80d0041bb1e414088050140930a0040a0064142802e0040910b014182364144b36b0041a30c4141870c0140a9080040860b41418e160140bd0a0040a40d4142803201408c0a41408d0b0140880600418a1f0140960d0040ac0801409b184140b812004099090040b204004087084140a7060140b8020040b4064141a62d0140b3024140970301408c044140aa0f0040af0a0240a8370000af1b4100bd080100a1024100a5040040a22e0040810441408d0a00408e0a0140ad0d004094120040ae06414094100140890541409c0a0140bb0b4140a0060040bd0b0040aa140140b3100100a32101008f2542008b170000aa06010099030100b911000094100000900600018d114101be180101a20f0100940a0000be050000a5040001ac164101a01400028b32000086070000ae030000b31b0000940641009a1a01009b080000890a0000b10a0000b6094100a00700009f214140ae040000b90b0040ac4a41418618024082184140962201009b044140920c01009a0d4140a6250040ae100100af1000008d050000890500009f0e0040b81400008a090000a1150000b40a0000bb080000a5060001983800409a0e0000ad0f4141bd5e0040801c0040961600409b120040b025004092130040bc150040851e414092160040ad1e01408f15004090254140941d0141802d41458fde0101001d0000004143948301
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"num_records": 240,
|
||||
"first": {
|
||||
"price": 0.01,
|
||||
"vol": 48,
|
||||
"unknown_1": 54
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
0500332635018aa001c20200ea0253fb1c4c32f1c64d3726350100cc0232ea02bc82104c573fb54d38263501149a01ae01721025384c2b39e74d3926350154cc0232cc02d2d52f4c4a73db4d3a263501006814c601a7ba224c3a6ec94d
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"num_records": 5,
|
||||
"first": {
|
||||
"open": 10.25,
|
||||
"high": 10.25,
|
||||
"low": 10.08,
|
||||
"close": 10.12,
|
||||
"vol": 41151820.0
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
0569
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"count": 26885
|
||||
}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"num_records": 1000,
|
||||
"first": {
|
||||
"code": "999999",
|
||||
"name": "上证指数",
|
||||
"pre_close": 11654847.33
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
01050100013630303030303e109d0f04040643acfaa60edd0f9889349483013a6ec94da7831cb2851841a6e2020001bd0788124102a8cd0187314203906aab114304a7b4018f1f4405ab96019812560f0000000000003e10
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"num_records": 1,
|
||||
"first": {
|
||||
"code": "600000",
|
||||
"pre_close": 9.93,
|
||||
"unknown_2": -1,
|
||||
"unknown_3": 22694
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
0a0083039e0f0000080083030000000800830300000008008303000000080083030000000800830300000008008303410000080083030000000800830300000008008403009483019f040200
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"num_records": 10,
|
||||
"first": {
|
||||
"hour": 14,
|
||||
"minute": 59,
|
||||
"price": 9.9,
|
||||
"vol": 0
|
||||
}
|
||||
}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"num_records": 87,
|
||||
"first": {
|
||||
"year": 1999,
|
||||
"month": 11,
|
||||
"day": 10,
|
||||
"category": 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
"""离线 fixture 测试:将录制的原始 body 字节喂给各命令 parse_response,验证解析结果。
|
||||
|
||||
fixtures/ 目录下每个 .hex 文件是一次真实服务器响应的 body(已解压),
|
||||
对应的 .json 文件记录关键预期值,供手工核对。
|
||||
此测试文件直接断言解析结果,无需网络连接。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import pytest
|
||||
|
||||
FIXTURES = pathlib.Path(__file__).parent.parent / "fixtures"
|
||||
|
||||
|
||||
def load_hex(name: str) -> bytes:
|
||||
return bytes.fromhex((FIXTURES / f"{name}.hex").read_text().strip())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# security_count
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_security_count_parse():
|
||||
from xmtdx.commands.security_count import GetSecurityCountCmd
|
||||
from xmtdx.models.enums import Market
|
||||
|
||||
body = load_hex("security_count")
|
||||
cmd = GetSecurityCountCmd(Market.SH)
|
||||
count = cmd.parse_response(body)
|
||||
|
||||
assert isinstance(count, int)
|
||||
assert count > 0
|
||||
# 体积固定为 2 字节,结果与录制时完全一致
|
||||
assert count == 26885
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# security_list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_security_list_parse():
|
||||
from xmtdx.commands.security_list import GetSecurityListCmd
|
||||
from xmtdx.models.enums import Market
|
||||
|
||||
body = load_hex("security_list")
|
||||
cmd = GetSecurityListCmd(Market.SH, 0)
|
||||
records = cmd.parse_response(body)
|
||||
|
||||
assert len(records) == 1000
|
||||
|
||||
r0 = records[0]
|
||||
assert r0.code == "999999"
|
||||
assert r0.name == "上证指数"
|
||||
# pre_close is a float; the index level is stored ×100 and has many decimal places
|
||||
assert abs(r0.pre_close - 11654847.33) < 1.0
|
||||
|
||||
# _raw present and non-empty for every record
|
||||
assert all(len(r._raw) > 0 for r in records)
|
||||
|
||||
|
||||
def test_security_list_gbk_no_crash():
|
||||
"""Bug #2 修复验证:GBK 解码不崩溃,所有记录均有 code。"""
|
||||
from xmtdx.commands.security_list import GetSecurityListCmd
|
||||
from xmtdx.models.enums import Market
|
||||
|
||||
body = load_hex("security_list")
|
||||
cmd = GetSecurityListCmd(Market.SH, 0)
|
||||
records = cmd.parse_response(body)
|
||||
assert all(r.code for r in records)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# security_bars
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_security_bars_parse():
|
||||
from xmtdx.commands.security_bars import GetSecurityBarsCmd
|
||||
from xmtdx.models.enums import Market, KlineCategory
|
||||
|
||||
body = load_hex("security_bars")
|
||||
cmd = GetSecurityBarsCmd(Market.SH, "600000", KlineCategory.DAY, 0, 5)
|
||||
bars = cmd.parse_response(body)
|
||||
|
||||
assert len(bars) == 5
|
||||
|
||||
b0 = bars[0]
|
||||
assert abs(b0.open - 10.25) < 0.01
|
||||
assert abs(b0.high - 10.25) < 0.01
|
||||
assert abs(b0.low - 10.08) < 0.01
|
||||
assert abs(b0.close - 10.12) < 0.01
|
||||
assert b0.vol > 0
|
||||
|
||||
# OHLC sanity: high ≥ open,close,low; low ≤ open,close
|
||||
for bar in bars:
|
||||
assert bar.high >= bar.open - 0.001
|
||||
assert bar.high >= bar.close - 0.001
|
||||
assert bar.low <= bar.open + 0.001
|
||||
assert bar.low <= bar.close + 0.001
|
||||
assert bar.vol > 0
|
||||
assert len(bar._raw) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# security_quotes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_security_quotes_parse():
|
||||
from xmtdx.commands.security_quotes import GetSecurityQuotesCmd
|
||||
from xmtdx.models.enums import Market
|
||||
|
||||
body = load_hex("security_quotes")
|
||||
cmd = GetSecurityQuotesCmd([(Market.SH, "600000")])
|
||||
quotes = cmd.parse_response(body)
|
||||
|
||||
assert len(quotes) == 1
|
||||
|
||||
q = quotes[0]
|
||||
assert q.code == "600000"
|
||||
assert abs(q.pre_close - 9.93) < 0.01
|
||||
|
||||
# unknown fields are captured (not discarded)
|
||||
assert hasattr(q, "unknown_2")
|
||||
assert hasattr(q, "unknown_3")
|
||||
assert hasattr(q, "unknown_5")
|
||||
assert hasattr(q, "unknown_6")
|
||||
assert hasattr(q, "unknown_7")
|
||||
assert hasattr(q, "unknown_8")
|
||||
assert hasattr(q, "rise_speed")
|
||||
assert len(q._raw) > 0
|
||||
|
||||
# fixed values from frozen fixture
|
||||
assert q.unknown_2 == -1
|
||||
assert q.unknown_3 == 22694
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# minute_time
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_minute_time_parse():
|
||||
from xmtdx.commands.minute_time import GetMinuteTimeDataCmd
|
||||
from xmtdx.models.enums import Market
|
||||
|
||||
body = load_hex("minute_time")
|
||||
cmd = GetMinuteTimeDataCmd(Market.SH, "600000")
|
||||
bars = cmd.parse_response(body)
|
||||
|
||||
assert len(bars) == 240
|
||||
|
||||
b0 = bars[0]
|
||||
assert isinstance(b0.price, float)
|
||||
assert isinstance(b0.vol, int)
|
||||
# Bug #5 fix: unknown_1 is preserved, not discarded
|
||||
assert hasattr(b0, "unknown_1")
|
||||
assert isinstance(b0.unknown_1, int)
|
||||
assert len(b0._raw) > 0
|
||||
|
||||
# fixed values
|
||||
assert abs(b0.price - 0.01) < 0.001
|
||||
assert b0.vol == 48
|
||||
assert b0.unknown_1 == 54
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# history_minute_time
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_history_minute_time_parse():
|
||||
from xmtdx.commands.minute_time import GetHistoryMinuteTimeDataCmd
|
||||
from xmtdx.models.enums import Market
|
||||
|
||||
body = load_hex("history_minute_time")
|
||||
cmd = GetHistoryMinuteTimeDataCmd(Market.SH, "600000", 20250108)
|
||||
bars = cmd.parse_response(body)
|
||||
|
||||
assert len(bars) == 240
|
||||
|
||||
b0 = bars[0]
|
||||
assert abs(b0.price - 10.29) < 0.01
|
||||
assert b0.vol == 10044
|
||||
assert hasattr(b0, "unknown_1")
|
||||
assert len(b0._raw) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# transaction (current day)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_transaction_parse():
|
||||
from xmtdx.commands.transaction import GetTransactionDataCmd
|
||||
from xmtdx.models.enums import Market
|
||||
|
||||
body = load_hex("transaction")
|
||||
cmd = GetTransactionDataCmd(Market.SH, "600000", 0, 10)
|
||||
recs = cmd.parse_response(body)
|
||||
|
||||
assert len(recs) == 10
|
||||
|
||||
r0 = recs[0]
|
||||
assert r0.hour == 14
|
||||
assert r0.minute == 59
|
||||
assert abs(r0.price - 9.9) < 0.01
|
||||
assert r0.vol == 0
|
||||
|
||||
# Bug #4 fix: unknown_last captured
|
||||
assert hasattr(r0, "unknown_last")
|
||||
assert len(r0._raw) > 0
|
||||
|
||||
# buyorsell: 0=buy, 1=sell, 2=neutral, 8=auction — field is an int
|
||||
for r in recs:
|
||||
assert isinstance(r.buyorsell, int)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# history_transaction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_history_transaction_parse():
|
||||
from xmtdx.commands.transaction import GetHistoryTransactionDataCmd
|
||||
from xmtdx.models.enums import Market
|
||||
|
||||
body = load_hex("history_transaction")
|
||||
cmd = GetHistoryTransactionDataCmd(Market.SH, "600000", 20250108, 0, 10)
|
||||
recs = cmd.parse_response(body)
|
||||
|
||||
assert len(recs) == 10
|
||||
|
||||
r0 = recs[0]
|
||||
assert r0.hour == 14
|
||||
assert r0.minute == 56
|
||||
assert abs(r0.price - 10.3) < 0.01
|
||||
assert r0.vol == 50
|
||||
|
||||
assert hasattr(r0, "unknown_last")
|
||||
assert len(r0._raw) > 0
|
||||
|
||||
for r in recs:
|
||||
assert isinstance(r.buyorsell, int)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# xdxr_info
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_xdxr_info_parse():
|
||||
from xmtdx.commands.xdxr_info import GetXdxrInfoCmd
|
||||
from xmtdx.models.enums import Market
|
||||
|
||||
body = load_hex("xdxr_info")
|
||||
cmd = GetXdxrInfoCmd(Market.SH, "600000")
|
||||
recs = cmd.parse_response(body)
|
||||
|
||||
assert len(recs) == 87
|
||||
|
||||
r0 = recs[0]
|
||||
assert r0.year == 1999
|
||||
assert r0.month == 11
|
||||
assert r0.day == 10
|
||||
assert r0.category == 5
|
||||
|
||||
# Bug #1 fix: each record has a unique date (not all reading from body[:7])
|
||||
dates = {(r.year, r.month, r.day) for r in recs}
|
||||
assert len(dates) > 1, "All records have the same date — Bug #1 not fixed!"
|
||||
|
||||
assert all(len(r._raw) > 0 for r in recs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# finance_info
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_finance_info_parse():
|
||||
from xmtdx.commands.finance_info import GetFinanceInfoCmd
|
||||
from xmtdx.models.enums import Market
|
||||
|
||||
body = load_hex("finance_info")
|
||||
cmd = GetFinanceInfoCmd(Market.SH, "600000")
|
||||
info = cmd.parse_response(body)
|
||||
|
||||
# Check key fields are present and reasonable
|
||||
assert info.liutong_guben > 0
|
||||
assert info.zong_guben > 0
|
||||
assert info.meigujing_zichan > 0
|
||||
|
||||
# Fixed values from frozen fixture
|
||||
assert abs(info.liutong_guben - 33305837500.0) < 1e6
|
||||
assert abs(info.zong_guben - 33305837500.0) < 1e6
|
||||
assert abs(info.meigujing_zichan - 22.13) < 0.1
|
||||
|
||||
assert len(info._raw) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# company_info_category
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_company_info_category_parse():
|
||||
from xmtdx.commands.company_info import GetCompanyInfoCategoryCmd
|
||||
from xmtdx.models.enums import Market
|
||||
|
||||
body = load_hex("company_info_category")
|
||||
cmd = GetCompanyInfoCategoryCmd(Market.SH, "600000")
|
||||
cats = cmd.parse_response(body)
|
||||
|
||||
assert len(cats) == 16
|
||||
|
||||
c0 = cats[0]
|
||||
assert c0.filename == "600000.txt"
|
||||
assert c0.start == 0
|
||||
assert c0.length == 11426
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# company_info_content
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_company_info_content_parse():
|
||||
from xmtdx.commands.company_info import GetCompanyInfoContentCmd
|
||||
from xmtdx.models.enums import Market
|
||||
|
||||
body = load_hex("company_info_content")
|
||||
cmd = GetCompanyInfoContentCmd(Market.SH, "600000", "600000.txt", 0, 11426)
|
||||
text = cmd.parse_response(body)
|
||||
|
||||
assert isinstance(text, str)
|
||||
assert len(text) == 8070
|
||||
assert "600000" in text
|
||||
assert "浦发银行" in text
|
||||
Reference in New Issue
Block a user