mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 19:14:19 +08:00
feat: add examples (01-08), fix index bars parsing, add ruff hook
- Add example scripts for all API categories (connection, market info, kline, minute, transaction, finance, block, fund flow) - Fix GetIndexBarsCmd: index bar records have 4 extra bytes (advance/ decline counts) that were not consumed, causing pos drift and corrupted dates/volumes for all records after the first - Fix price_limits.py example (SecurityQuote has no name attr) - Fix finance_info.py display (scientific notation -> formatted numbers) - Add PostToolUse ruff hook (scripts/ruff_hook.py) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
ace1099ab0
commit
7fd6e610cf
@@ -35,6 +35,8 @@ from .models import (
|
||||
TransactionRecord,
|
||||
XdxrRecord,
|
||||
)
|
||||
from .ex.client import AsyncExTdxClient, ExTdxClient
|
||||
from .ex.models import KNOWN_EX_HOSTS
|
||||
from .transport.sync import KNOWN_HOSTS, ping_all
|
||||
|
||||
__all__ = [
|
||||
@@ -59,6 +61,10 @@ __all__ = [
|
||||
"TdxConnectionError",
|
||||
"TdxDecodeError",
|
||||
"TdxCommandError",
|
||||
# 扩展行情
|
||||
"ExTdxClient",
|
||||
"AsyncExTdxClient",
|
||||
"KNOWN_EX_HOSTS",
|
||||
# 工具
|
||||
"ping_all",
|
||||
"KNOWN_HOSTS",
|
||||
|
||||
+145
-43
@@ -1,7 +1,11 @@
|
||||
"""高层行情 API:TdxClient(同步)和 AsyncTdxClient(asyncio)。"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from collections.abc import Awaitable, Callable
|
||||
from types import TracebackType
|
||||
from typing import TypeVar
|
||||
@@ -124,6 +128,43 @@ def _historical_fund_flow_from_records(
|
||||
# 同步客户端
|
||||
# ============================================================
|
||||
|
||||
_CACHE_DIR = Path.home() / ".xmtdx" / "cache"
|
||||
_CACHE_MAX_AGE = 86400 # 1 天
|
||||
|
||||
|
||||
def _serialize_stocks(stocks: list[SecurityInfo]) -> list[dict]:
|
||||
return [{k: v for k, v in asdict(s).items() if k != "_raw"} for s in stocks]
|
||||
|
||||
|
||||
def _deserialize_stocks(data: list[dict]) -> list[SecurityInfo]:
|
||||
return [SecurityInfo(**{**d, "market": Market(d["market"])}) for d in data]
|
||||
|
||||
|
||||
def _load_cache() -> list[SecurityInfo] | None:
|
||||
path = _CACHE_DIR / "security_list_all.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
raw = json.loads(path.read_text("utf-8"))
|
||||
updated = datetime.fromisoformat(raw["updated"])
|
||||
if (datetime.now() - updated).total_seconds() > _CACHE_MAX_AGE:
|
||||
return None
|
||||
return _deserialize_stocks(raw["data"])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _save_cache(stocks: list[SecurityInfo]) -> None:
|
||||
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"updated": datetime.now().isoformat(),
|
||||
"count": len(stocks),
|
||||
"data": _serialize_stocks(stocks),
|
||||
}
|
||||
(_CACHE_DIR / "security_list_all.json").write_text(
|
||||
json.dumps(data, ensure_ascii=False), "utf-8"
|
||||
)
|
||||
|
||||
|
||||
class TdxClient:
|
||||
"""同步通达信行情客户端,支持 IP 优选与断线自动重连。
|
||||
@@ -233,46 +274,68 @@ class TdxClient:
|
||||
"""获取证券列表(每页约1000条,按 start 分页)。"""
|
||||
return self._execute(GetSecurityListCmd(market, start))
|
||||
|
||||
def get_security_list_all(self) -> list[SecurityInfo]:
|
||||
def get_security_list_all(self, pages: int | str = "all") -> list[SecurityInfo]:
|
||||
"""获取沪深 A 股完整证券列表,并自动挂载行业信息。
|
||||
|
||||
Args:
|
||||
pages: 拉取页数。每个市场每页 1000 条。
|
||||
"all" 拉取全部(默认,结果会缓存到本地文件)。
|
||||
整数 N 表示每个市场只拉前 N 页,不缓存。
|
||||
|
||||
注意:
|
||||
`Market.BJ` 的证券列表请求长期存在服务器超时问题,当前版本暂不纳入此方法。
|
||||
若需 BJ 名单,应改由 `base_info.zip` 等文件离线解析获得。
|
||||
"""
|
||||
# 1. 尝试获取行业配置
|
||||
industry_map = {}
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
if pages == "all":
|
||||
cached = _load_cache()
|
||||
if cached is not None:
|
||||
log.info("从缓存加载沪深 A 股列表,共 %d 只", len(cached))
|
||||
return cached
|
||||
|
||||
# 计算每个市场的最大起始偏移
|
||||
def _max_start(count: int) -> int:
|
||||
if pages == "all":
|
||||
return count
|
||||
return min(count, int(pages) * 1000)
|
||||
|
||||
# 尝试获取行业配置
|
||||
industry_map: dict[str, tuple[str, str]] = {}
|
||||
try:
|
||||
cfg_data = self.get_report_file("tdxhy.cfg")
|
||||
if cfg_data:
|
||||
industry_map = parse_tdxhy_cfg(cfg_data)
|
||||
log.info("行业配置已加载,共 %d 条映射", len(industry_map))
|
||||
except Exception:
|
||||
pass
|
||||
log.warning("无法获取 tdxhy.cfg,行业字段将为空")
|
||||
|
||||
all_stocks: list[SecurityInfo] = []
|
||||
# 注意:Market.BJ 证券列表请求常年超时,短期降级为仅 SH/SZ;
|
||||
# BJ 列表需解析 base_info.zip 获得(待实现)。
|
||||
for market in [Market.SH, Market.SZ]:
|
||||
count = self.get_security_count(market)
|
||||
for start in range(0, count, 1000):
|
||||
stocks = self.get_security_list(market, start)
|
||||
limit = _max_start(count)
|
||||
total_pages = (limit + 999) // 1000
|
||||
for page_idx, start in enumerate(range(0, limit, 1000)):
|
||||
try:
|
||||
stocks = self.get_security_list(market, start)
|
||||
except Exception:
|
||||
log.warning("%s 第 %d/%d 页获取失败,跳过", market.name, page_idx + 1, total_pages)
|
||||
continue
|
||||
log.info("%s 第 %d/%d 页: %d 条", market.name, page_idx + 1, total_pages, len(stocks))
|
||||
for s in stocks:
|
||||
# 精确 A 股过滤规则
|
||||
is_a_share = False
|
||||
if market == Market.SH:
|
||||
# 沪市 A 股:60xxxx, 68xxxx
|
||||
if s.code.startswith(("60", "68")):
|
||||
is_a_share = True
|
||||
elif market == Market.SZ:
|
||||
# 深市 A 股:00xxxx, 30xxxx
|
||||
if s.code.startswith(("00", "30")):
|
||||
is_a_share = True
|
||||
|
||||
is_a_share = (
|
||||
(market == Market.SH and s.code.startswith(("60", "68")))
|
||||
or (market == Market.SZ and s.code.startswith(("00", "30")))
|
||||
)
|
||||
if is_a_share:
|
||||
# 挂载行业信息
|
||||
if s.code in industry_map:
|
||||
s.industry_tdx, s.industry_sw = industry_map[s.code]
|
||||
all_stocks.append(s)
|
||||
|
||||
log.info("沪深 A 股总数: %d", len(all_stocks))
|
||||
|
||||
if pages == "all":
|
||||
_save_cache(all_stocks)
|
||||
|
||||
return all_stocks
|
||||
|
||||
def get_security_quotes(
|
||||
@@ -440,15 +503,20 @@ class TdxClient:
|
||||
`suspended_count` 是 `total - up - down - neutral` 的残差估算值,
|
||||
用于保证计数守恒,不应视为协议已明确验证的停牌字段。
|
||||
"""
|
||||
# 通达信中 880005 是全市场行情统计代码
|
||||
quotes = self.get_security_quotes([(Market.SH, "880005")])
|
||||
# 通达信中 880005 是全市场行情统计,880001 是总市值指数,880006 是涨跌停统计
|
||||
quotes = self.get_security_quotes([
|
||||
(Market.SH, "880005"), (Market.SH, "880001"), (Market.SH, "880006"),
|
||||
])
|
||||
if not quotes:
|
||||
raise RuntimeError("无法获取市场统计数据")
|
||||
q = quotes[0]
|
||||
up = int(q.price)
|
||||
down = int(q.pre_close)
|
||||
down = int(q.open)
|
||||
neutral = int(q.low)
|
||||
total = int(q.high)
|
||||
market_cap = quotes[1].price * 1e10 if len(quotes) > 1 else 0.0
|
||||
limit_down = int(quotes[2].open) if len(quotes) > 2 else 0
|
||||
limit_up = int(quotes[2].price) if len(quotes) > 2 else 0
|
||||
return MarketStat(
|
||||
up_count=up,
|
||||
down_count=down,
|
||||
@@ -457,6 +525,9 @@ class TdxClient:
|
||||
total_count=total,
|
||||
total_amount=q.amount,
|
||||
total_volume=q.vol,
|
||||
total_market_cap=market_cap,
|
||||
limit_up_count=limit_up,
|
||||
limit_down_count=limit_down,
|
||||
)
|
||||
|
||||
def _collect_transaction_records(
|
||||
@@ -668,41 +739,64 @@ class AsyncTdxClient:
|
||||
async def get_security_list(self, market: Market, start: int) -> list[SecurityInfo]:
|
||||
return await self._execute(GetSecurityListCmd(market, start))
|
||||
|
||||
async def get_security_list_all(self) -> list[SecurityInfo]:
|
||||
async def get_security_list_all(self, pages: int | str = "all") -> list[SecurityInfo]:
|
||||
"""获取沪深 A 股完整证券列表,并自动挂载行业信息。
|
||||
|
||||
Args:
|
||||
pages: 拉取页数。每个市场每页 1000 条。
|
||||
"all" 拉取全部(默认,结果会缓存到本地文件)。
|
||||
整数 N 表示每个市场只拉前 N 页,不缓存。
|
||||
|
||||
注意:
|
||||
`Market.BJ` 的证券列表请求长期存在服务器超时问题,当前版本暂不纳入此方法。
|
||||
若需 BJ 名单,应改由 `base_info.zip` 等文件离线解析获得。
|
||||
"""
|
||||
industry_map = {}
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
if pages == "all":
|
||||
cached = _load_cache()
|
||||
if cached is not None:
|
||||
log.info("从缓存加载沪深 A 股列表,共 %d 只", len(cached))
|
||||
return cached
|
||||
|
||||
def _max_start(count: int) -> int:
|
||||
if pages == "all":
|
||||
return count
|
||||
return min(count, int(pages) * 1000)
|
||||
|
||||
industry_map: dict[str, tuple[str, str]] = {}
|
||||
try:
|
||||
cfg_data = await self.get_report_file("tdxhy.cfg")
|
||||
if cfg_data:
|
||||
industry_map = parse_tdxhy_cfg(cfg_data)
|
||||
log.info("行业配置已加载,共 %d 条映射", len(industry_map))
|
||||
except Exception:
|
||||
pass
|
||||
log.warning("无法获取 tdxhy.cfg,行业字段将为空")
|
||||
|
||||
all_stocks: list[SecurityInfo] = []
|
||||
# 注意:Market.BJ 证券列表请求常年超时,短期降级为仅 SH/SZ;
|
||||
# BJ 列表需解析 base_info.zip 获得(待实现)。
|
||||
for market in [Market.SH, Market.SZ]:
|
||||
count = await self.get_security_count(market)
|
||||
for start in range(0, count, 1000):
|
||||
stocks = await self.get_security_list(market, start)
|
||||
limit = _max_start(count)
|
||||
total_pages = (limit + 999) // 1000
|
||||
for page_idx, start in enumerate(range(0, limit, 1000)):
|
||||
try:
|
||||
stocks = await self.get_security_list(market, start)
|
||||
except Exception:
|
||||
log.warning("%s 第 %d/%d 页获取失败,跳过", market.name, page_idx + 1, total_pages)
|
||||
continue
|
||||
log.info("%s 第 %d/%d 页: %d 条", market.name, page_idx + 1, total_pages, len(stocks))
|
||||
for s in stocks:
|
||||
is_a_share = False
|
||||
if market == Market.SH:
|
||||
if s.code.startswith(("60", "68")):
|
||||
is_a_share = True
|
||||
elif market == Market.SZ:
|
||||
if s.code.startswith(("00", "30")):
|
||||
is_a_share = True
|
||||
|
||||
is_a_share = (
|
||||
(market == Market.SH and s.code.startswith(("60", "68")))
|
||||
or (market == Market.SZ and s.code.startswith(("00", "30")))
|
||||
)
|
||||
if is_a_share:
|
||||
if s.code in industry_map:
|
||||
s.industry_tdx, s.industry_sw = industry_map[s.code]
|
||||
all_stocks.append(s)
|
||||
|
||||
log.info("沪深 A 股总数: %d", len(all_stocks))
|
||||
if pages == "all":
|
||||
_save_cache(all_stocks)
|
||||
return all_stocks
|
||||
|
||||
async def get_security_quotes(
|
||||
@@ -836,15 +930,20 @@ class AsyncTdxClient:
|
||||
`suspended_count` 是 `total - up - down - neutral` 的残差估算值,
|
||||
用于保证计数守恒,不应视为协议已明确验证的停牌字段。
|
||||
"""
|
||||
# 通达信中 880005 是全市场行情统计代码
|
||||
quotes = await self.get_security_quotes([(Market.SH, "880005")])
|
||||
# 通达信中 880005 是全市场行情统计,880001 是总市值指数,880006 是涨跌停统计
|
||||
quotes = await self.get_security_quotes([
|
||||
(Market.SH, "880005"), (Market.SH, "880001"), (Market.SH, "880006"),
|
||||
])
|
||||
if not quotes:
|
||||
raise RuntimeError("无法获取市场统计数据")
|
||||
q = quotes[0]
|
||||
up = int(q.price)
|
||||
down = int(q.pre_close)
|
||||
down = int(q.open)
|
||||
neutral = int(q.low)
|
||||
total = int(q.high)
|
||||
market_cap = quotes[1].price * 1e10 if len(quotes) > 1 else 0.0
|
||||
limit_down = int(quotes[2].open) if len(quotes) > 2 else 0
|
||||
limit_up = int(quotes[2].price) if len(quotes) > 2 else 0
|
||||
return MarketStat(
|
||||
up_count=up,
|
||||
down_count=down,
|
||||
@@ -853,6 +952,9 @@ class AsyncTdxClient:
|
||||
total_count=total,
|
||||
total_amount=q.amount,
|
||||
total_volume=q.vol,
|
||||
total_market_cap=market_cap,
|
||||
limit_up_count=limit_up,
|
||||
limit_down_count=limit_down,
|
||||
)
|
||||
|
||||
async def _collect_transaction_records(
|
||||
|
||||
@@ -103,8 +103,55 @@ class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]):
|
||||
|
||||
|
||||
class GetIndexBarsCmd(GetSecurityBarsCmd):
|
||||
"""获取指数 K 线(请求格式与股票 K 线相同,服务器端按指数逻辑处理)。
|
||||
"""获取指数 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
|
||||
|
||||
@@ -12,6 +12,9 @@ class MarketStat:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user