diff --git a/README.md b/README.md index 0f2b079..609feb2 100644 --- a/README.md +++ b/README.md @@ -1,634 +1,482 @@ # easy-tdx [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![PyPI](https://img.shields.io/pypi/v/easy-tdx.svg)](https://pypi.org/project/easy-tdx/) -通达信(tdx)是中国使用最广泛的券商行情终端之一,其私有 TCP 协议长期缺乏官方 SDK。[pytdx](https://github.com/rainx/pytdx) 率先完成了协议逆向与离线数据读取,为整个生态奠定了基础;[mootdx](https://github.com/mootdx/mootdx) 在此之上做了工程化封装,让更多开发者得以使用;[xmtdx](https://github.com/minionszyw/xmtdx) 进一步探索了现代 Python 接口设计。 - -easy-tdx 站在这些项目的肩膀上,从协议层重新实现:LEB128 价格编解码、自定义浮点成交量、帧解压缩与握手——每一层都有对应的离线 fixture 测试。commands 层不含 IO,与 transport 完全解耦;同步 + asyncio 双接口;strict mypy 通过;零运行时依赖;每条记录保留原始字节。覆盖标准行情、扩展市场(期货/港股/外盘)、离线本地数据读取、专业财务数据全场景。 - -感谢 rainx、mootdx 社区及 minionszyw 的开创性工作——没有他们,就不会有这个项目。 - -详见 [NOTICE](NOTICE) 和 [LICENSE](LICENSE) 文件。 - -## 特性 - -- **零依赖**:纯标准库,Python >= 3.10 -- **同步 + asyncio 双接口**:`TdxClient` / `AsyncTdxClient`,commands 层不含任何 IO -- **完整类型注解**:strict `mypy` + `ruff` 通过 -- **高可用传输**:同步/异步均支持 `ping_all()`、`from_best_host()`、断线自动重连 -- **修复 pytdx 已知 bug**(见下文) -- **保留原始字节**:每条数据记录含 `_raw: bytes`,未知字段以 `unknown_N` 命名而非丢弃 -- **保活心跳机制**:`AsyncTdxClient` 自动发送心跳包,确保长连接生产环境稳定性 -- **扩展行情**:`ExTdxClient` / `AsyncExTdxClient` 支持期货、港股、外盘等扩展市场(端口 7727) -- **离线数据读取**:从本地通达信安装目录直接读取日线、分钟线、财务、板块、股本变迁等数据,无需网络 -- **专业财务数据**:通过计算服务器下载历史财报 ZIP 文件并解析 +通达信 TCP 行情协议客户端。支持 A 股、港股、美股、期货全市场;内置 `easy-tdx` CLI 工具,默认 JSON 输出,天然适配 Claude Code、OpenClaw、Hermes 等 AI Agent 工具链。提供同步 + asyncio 双接口;strict mypy 通过;每一层编解码都有离线 fixture 测试覆盖。 ## 安装 ```bash -pip install -e . # 开发模式 -pip install -e ".[dev]" # 含测试/类型检查工具 -pip install -e ".[pandas]" # 含 pandas(可选) +pip install easy-tdx ``` -## 快速开始 +安装后自动注册 `easy-tdx` CLI 命令: -### 连接与基本查询 +```bash +easy-tdx --help +``` + +开发模式: + +```bash +pip install -e ".[dev]" +``` + +## CLI 参考 + +`easy-tdx` 默认输出 JSON(一行一条记录),`--table` 切换表格,`--output csv` 输出 CSV。 + +### 基础 + +```bash +easy-tdx ping # 服务器测速 +easy-tdx version # 版本号 +``` + +### 行情 + +```bash +# K 线 +easy-tdx kline SZ 000001 --count 30 --table +easy-tdx kline SH 600519 --period 5MIN --adjust QFQ + +# 实时报价 +easy-tdx quote "SZ 000001,SH 600519" --table + +# 市场分类报价(按涨幅排序) +easy-tdx quote-list A --count 20 --table +easy-tdx quote-list KCB --sort TOTAL_AMOUNT --order ASC +easy-tdx quote-list CYB --count 50 +``` + +### 分时 / 成交 + +```bash +easy-tdx tick SZ 000001 --table +easy-tdx tick SH 600519 --days 5 +easy-tdx tick SZ 000001 --date 20250115 + +easy-tdx transaction SZ 000001 --count 100 --table +easy-tdx transaction SH 600519 --date 20250115 +``` + +### 板块 + +```bash +easy-tdx board-list --type GN --table +easy-tdx board-list --type HY --count 200 +easy-tdx board-members 881001 --table +easy-tdx belong-board SZ 000001 --table +``` + +### 资金 / 监控 + +```bash +easy-tdx capital-flow SH 600519 --table +easy-tdx auction SZ 000001 --table +easy-tdx unusual SH --count 100 --table +easy-tdx market-stat --table +easy-tdx server-info --table +easy-tdx symbol-info SZ 000001 --table +``` + +### 财务 + +```bash +easy-tdx f10 SH 600519 # F10 公司信息 +easy-tdx fund-flow SH 600519 # 历史资金流向 +``` + +### 扩展市场(港股/美股/期货) + +```bash +easy-tdx ex markets # 列出可用市场 +easy-tdx ex kline HK_MAIN_BOARD 00700 --count 30 --table # 港股 K 线 +easy-tdx ex kline US_STOCK AAPL --table # 美股 K 线 +easy-tdx ex quote US_STOCK TSLA --table # 美股报价 +easy-tdx ex quote-list HK_MAIN_BOARD --table # 港股商品列表 +easy-tdx ex tick HK_MAIN_BOARD 00700 --table # 港股分时 +``` + +## CLI 命令汇总 + +| 命令 | 说明 | +|------|------| +| `ping` | 服务器延迟测速 | +| `version` | 版本号 | +| `kline` | K 线(日/周/月/分钟,支持复权) | +| `quote` | 实时报价(单只/批量) | +| `quote-list` | 市场分类排序报价(A/SH/SZ/KCB/CYB) | +| `tick` | 分时图(单日/多日/历史) | +| `transaction` | 逐笔成交 | +| `board-list` | 板块列表(行业/概念/风格) | +| `board-members` | 板块成分股报价 | +| `belong-board` | 个股所属板块 | +| `capital-flow` | 资金流向 | +| `auction` | 集合竞价 | +| `unusual` | 市场异动 | +| `market-stat` | 全市场涨跌统计 | +| `server-info` | 服务器交易时段 | +| `symbol-info` | 个股特征快照 | +| `f10` | F10 公司信息 | +| `fund-flow` | 历史资金流向 | +| `ex kline` | 扩展市场 K 线 | +| `ex quote` | 扩展市场报价 | +| `ex quote-list` | 扩展市场商品列表 | +| `ex tick` | 扩展市场分时 | +| `ex markets` | 列出可用扩展市场 | + +## Python API + +### 连接管理 + +所有客户端支持 `from_best_host()` 自动选最低延迟服务器: + +```python +from easy_tdx import MacClient + +with MacClient.from_best_host() as c: + df = c.get_stock_kline(...) +``` + +| 客户端 | 端口 | 覆盖范围 | +|--------|------|----------| +| `MacClient` / `AsyncMacClient` | 7709 | A 股行情(MAC 协议,推荐) | +| `MacExClient` / `AsyncMacExClient` | 7727 | 港股/美股/期货(MAC 协议) | +| `UnifiedTdxClient` / `AsyncUnifiedTdxClient` | 自动 | A 股 + 扩展市场统一入口 | +| `TdxClient` / `AsyncTdxClient` | 7709 | A 股行情(标准协议) | + +### MAC 协议(推荐) + +#### 报价 + +```python +from easy_tdx import MacClient, Market, Category, SortType, SortOrder + +with MacClient.from_best_host() as c: + # 批量报价(最多 80 只/次) + df = c.get_stock_quotes([(Market.SH, "600519"), (Market.SZ, "000858")]) + + # 市场分类排序报价 + df = c.get_stock_quotes_list( + Category.A, count=20, + sort_type=SortType.CHANGE_PCT, + sort_order=SortOrder.DESC, + ) +``` + +返回列:`market, code, name` + 动态字段(`pre_close, open, high, low, close, vol, amount, turnover, vol_ratio` 等)。 + +#### K 线(支持复权) + +```python +from easy_tdx import MacClient, Market, Period, Adjust + +with MacClient.from_best_host() as c: + # 日K前复权 + df = c.get_stock_kline(Market.SH, "600519", Period.DAILY, count=10, adjust=Adjust.QFQ) + # 5分钟线 + df = c.get_stock_kline(Market.SZ, "000001", Period.MIN_5, count=100) +``` + +返回列:`datetime, open, close, high, low, vol, amount`。 + +#### 分时 + +```python +with MacClient.from_best_host() as c: + df = c.get_tick_chart(Market.SH, "600519") # 单日分时 + df = c.get_tick_charts(Market.SH, "600519", days=3) # 多日分时(最多5天) + df = c.get_chart_sampling(Market.SH, "600519") # 240点缩略采样 +``` + +#### 逐笔成交 + +```python +with MacClient.from_best_host() as c: + df = c.get_transactions(Market.SH, "600519", count=100) + df = c.get_transactions(Market.SH, "600519", count=100, date=20250115) +``` + +#### 板块 + +```python +from easy_tdx import BoardType + +with MacClient.from_best_host() as c: + df = c.get_board_list(BoardType.GN) # 概念板块 + df = c.get_board_members("881001", sort_type=SortType.CHANGE_PCT) + df = c.get_belong_board(Market.SZ, "000001") # 个股所属板块 +``` + +#### 资金流向 + +```python +with MacClient.from_best_host() as c: + df = c.get_capital_flow(Market.SH, "600519") +``` + +返回列:`date, main_in, main_out, main_net, small_in/out/net, mid_in/out/net, large_in/out/net`。 + +#### 监控 + +```python +with MacClient.from_best_host() as c: + df = c.get_auction(Market.SH, "600519") # 集合竞价 + df = c.get_unusual(Market.SH) # 市场异动 + df = c.get_symbol_info(Market.SZ, "000001") # 个股特征快照 + df = c.get_server_info() # 服务器交易时段 +``` + +### 扩展市场 + +```python +from easy_tdx import MacExClient, ExMarket, Period + +with MacExClient.from_best_host() as c: + count = c.goods_count(ExMarket.HK_MAIN_BOARD) + df = c.goods_list(ExMarket.HK_MAIN_BOARD, start=0, count=50) + df = c.goods_kline(ExMarket.US_STOCK, "AAPL", Period.DAILY, count=10) + df = c.goods_quotes([(ExMarket.HK_MAIN_BOARD, "00700")]) + df = c.goods_tick_chart(ExMarket.HK_MAIN_BOARD, "00700") + df = c.goods_transaction(ExMarket.HK_MAIN_BOARD, "00700", count=100) +``` + +### 统一客户端 + +```python +from easy_tdx import UnifiedTdxClient, ExMarket, Market, Period + +with UnifiedTdxClient() as client: + # A 股 -- 自动路由到 MacClient + df = client.get_stock_kline(Market.SH, "600519", Period.DAILY, count=5) + df = client.get_stock_quotes([(Market.SH, "600519")]) + df = client.get_board_list() + + # 扩展市场 -- 自动路由到 MacExClient + df = client.goods_kline(ExMarket.HK_MAIN_BOARD, "00700", Period.DAILY, count=5) +``` + +### 标准协议 ```python from easy_tdx import TdxClient, Market, KlineCategory -# 手动指定服务器 -with TdxClient("180.153.18.170") as c: - count = c.get_security_count(Market.SH) - print(f"沪市证券总数: {count}") - -# 自动优选最低延迟服务器 with TdxClient.from_best_host() as c: - df = c.get_security_bars(Market.SH, "600000", KlineCategory.DAY, 0, 5) - print(df.to_string(index=False)) - # date open close high low vol amount - # 2025-01-02 10.25 10.12 10.25 10.08 108154752.0 1.078280e+09 -``` - -### asyncio - -```python -import asyncio -from easy_tdx import AsyncTdxClient, Market, KlineCategory - -async def main(): - async with AsyncTdxClient.from_best_host() as c: - df = await c.get_security_bars( - Market.SH, "600000", KlineCategory.DAY, 0, 5 - ) - print(df.to_string(index=False)) - -asyncio.run(main()) -``` - -### 服务器测速 - -```python -from easy_tdx import TdxClient - -# 测速并排序 -results = TdxClient.ping_all() -for host, latency in results: - print(f"{host} {latency * 1000:.0f} ms") -``` - -## API 参考 - -### 连接管理 - -| 方法 | 说明 | -|------|------| -| `TdxClient(host, port=7709, timeout=15.0)` | 指定服务器创建客户端 | -| `TdxClient.from_best_host(ping_timeout=5.0)` | 自动选延迟最低的服务器 | -| `TdxClient.ping_all(timeout=5.0)` | 并发测速,返回 `[(host, seconds), ...]` | -| `AsyncTdxClient` / `AsyncTdxClient.from_best_host()` | 异步版,接口一一对应 | - -内置服务器列表 `KNOWN_HOSTS`(8 台)和计算服务器 `CALC_HOSTS`(1 台)。 - -### 市场信息 - -```python -with TdxClient.from_best_host() as c: - # 市场证券总数 count = c.get_security_count(Market.SH) - - # 证券列表(分页,每页约 1000 条) stocks = c.get_security_list(Market.SH, start=0) - # stocks[0].code / .name / .pre_close / .industry_tdx / .industry_sw - - # 沪深 A 股完整列表(自动挂载行业信息,本地缓存 1 天) - all_stocks = c.get_security_list_all() - - # 批量实时五档行情(最多 80 只/次) - quotes = c.get_security_quotes([ - (Market.SH, "600000"), # 浦发银行 - (Market.SH, "600519"), # 贵州茅台 - (Market.SZ, "000001"), # 平安银行 - (Market.SZ, "000858"), # 五粮液 - ]) - # quotes[0].price / .pre_close / .open / .high / .low / .bid1..bid5 / .ask1..ask5 - - # 全市场涨跌统计 - stat = c.get_market_stat() - # stat.up_count / .down_count / .neutral_count / .total_amount / .total_market_cap -``` - -### K 线数据 - -```python -from easy_tdx import Market, KlineCategory - -with TdxClient.from_best_host() as c: - # 个股 K 线 + quotes = c.get_security_quotes([(Market.SH, "600000"), (Market.SZ, "000001")]) bars = c.get_security_bars(Market.SZ, "002176", KlineCategory.DAY, 0, 100) - - # 指数 K 线(常用指数代码:上证 "999999",深成 "399001",创业板 "399006") - bars = c.get_index_bars(Market.SH, "999999", KlineCategory.DAY, 0, 10) -``` - -K 线类别: - -``` -KlineCategory.MIN_1 MIN_5 MIN_15 MIN_30 MIN_60 -KlineCategory.DAY WEEK MONTH YEAR -``` - -K 线字段:`date`(日线及以上)或 `datetime`(分钟线) `open` `close` `high` `low` `vol` `amount` - -### 分时数据 - -```python -with TdxClient.from_best_host() as c: - # 今日分时(240 条) - bars = c.get_minute_time_data(Market.SH, "600000") - - # 历史某日分时,date 为 YYYYMMDD 格式整数 - bars = c.get_history_minute_time_data(Market.SH, "600000", 20250110) -``` - -分时字段:`datetime` `price` `vol` - -### 逐笔成交 - -```python -with TdxClient.from_best_host() as c: - # 当日逐笔成交(分页) - records = c.get_transaction_data(Market.SH, "600000", 0, 20) - - # 历史逐笔成交 - records = c.get_history_transaction_data(Market.SH, "600000", 20250110, 0, 20) -``` - -成交字段:`datetime` `price` `vol` `buyorsell`(0=买, 1=卖, 2=中性, 8=集合竞价) - -### 财务与公司信息 - -```python -from easy_tdx import XDXR_CATEGORY_NAMES - -with TdxClient.from_best_host() as c: - # 除权除息历史 - records = c.get_xdxr_info(Market.SH, "600519") - # records[0].fenhong / .songzhuangu / .peigujia / .peigu - - # 最新财务数据 - info = c.get_finance_info(Market.SH, "600519") - # info.zong_guben / .liutong_guben / .jing_lirun / .zhuying_shouru / ... - - # 涨跌停价计算 - quotes = c.get_security_quotes([(Market.SH, "600519")]) - limit_up, limit_down = c.get_price_limits( - Market.SH, "600519", "贵州茅台", quotes[0].pre_close - ) - - # 公司信息目录 - categories = c.get_company_info_category(Market.SH, "600519") - for cat in categories: - print(cat.name, cat.filename, cat.start, cat.length) - - # 公司信息内容 - content = c.get_company_info_content( - Market.SH, "600519", cat.filename, cat.start, cat.length - ) -``` - -### 板块信息 - -```python -with TdxClient.from_best_host() as c: - # 行业/指数板块 - blocks = c.get_block_info("block_zs.dat") - # 概念板块 - blocks = c.get_block_info("block_gn.dat") - # 风格板块 - blocks = c.get_block_info("block_fg.dat") - # blocks[0].name / .category / .count / .codes -``` - -### 资金流向 - -```python -with TdxClient.from_best_host() as c: - # 当日资金流向(超大/大/中/小单) + minute = c.get_minute_time_data(Market.SH, "600000") + trades = c.get_transaction_data(Market.SH, "600000", 0, 20) flow = c.get_fund_flow(Market.SH, "600519") - # flow.super_in / .super_out / .large_in / .large_out / .main_net_inflow - - # 历史日线资金流向序列 - flows = c.get_history_fund_flow(Market.SH, "600519", 0, 10) - # flows[0].date / .super_in / .main_net_inflow + blocks = c.get_block_info("block_gn.dat") + xdxr = c.get_xdxr_info(Market.SH, "600519") + stat = c.get_market_stat() ``` -### 文件下载 +`AsyncTdxClient` 提供对应的 `async def` 方法,接口一一对应。 -```python -from easy_tdx import CALC_HOSTS +### 离线数据读取 -with TdxClient.from_best_host() as c: - # 行情服务器可用的文件 - data = c.get_report_file("tdxhy.cfg") # 行业映射配置 - data = c.get_report_file("block_gn.dat") # 概念板块 - - # 计算服务器:专业财务数据 - with TdxClient(CALC_HOSTS[0]) as calc: - file_list = calc.get_financial_file_list() - # file_list[0].filename / .filesize / .hash - - zip_data = calc.get_financial_file("tdxfin/gpcw20260331.zip") - records = calc.get_financial_records("tdxfin/gpcw20260331.zip") - # records[0].market / .code / .report_date / .fields -``` - -### 扩展行情(期货、港股、外盘) - -```python -from easy_tdx import ExTdxClient - -# 扩展行情服务器端口 7727 -with ExTdxClient() as c: - markets = c.get_markets() # 可用市场列表 - count = c.get_instrument_count() # 品种总数 - instruments = c.get_instrument_info(0, 50) # 品种信息(分页) - quote = c.get_instrument_quote(market, code) # 单品种行情 - - # K 线(支持日期范围查询) - bars = c.get_instrument_bars(market, code, category, start, count) - bars = c.get_history_instrument_bars_range(market, code, date_start, date_end) - - # 分时 / 逐笔 - minute = c.get_minute_time_data(market, code) - trades = c.get_transaction_data(market, code, start, count) -``` - -`AsyncExTdxClient` 提供与同步版对应的 `async def` 方法。 - -## 离线数据读取 - -从本地通达信安装目录直接读取数据文件,无需网络连接。离线模块的路径检测优先级: - -1. `TDX_HOME` 环境变量 -2. 平台常见路径猜测(Windows: `C:\new_jyplug`、`C:\new_tdx` 等) - -```python -# Windows -set TDX_HOME=C:\new_jyplug - -# Linux/macOS -export TDX_HOME=/opt/new_tdx -``` - -### 日线 K 线 +无需网络,从本地通达信安装目录直接读取: ```python from easy_tdx.offline import detect_tdx_home, read_daily_bars, find_daily_bar_file from easy_tdx import Market home = detect_tdx_home() - -# 通过 市场+代码 自动定位文件 filepath = find_daily_bar_file(Market.SH, "600000") bars = read_daily_bars(filepath) - -for bar in bars[-10:]: - print(f"{bar.year}-{bar.month:02d}-{bar.day:02d} " - f"开:{bar.open:.2f} 收:{bar.close:.2f} 量:{bar.vol:.0f}") ``` -文件位于 `vipdoc/{sh,sz}/lday/`,如 `sh600000.day`。自动识别证券类型(A 股/B 股/指数/基金/债券)并应用对应的价格和成交量系数。 +支持:日线、分钟线、扩展市场日线、板块、股本变迁、历史财务数据。 -### 分钟 K 线 +## 枚举参考 -```python -from easy_tdx.offline import ( - read_5min_bars, read_lc_min_bars, - find_5min_bar_file, find_lc1_bar_file, find_lc5_bar_file, -) -from easy_tdx import Market +### Period(K 线周期) -# .5 文件(OHLC 为整数 / 100) -filepath = find_5min_bar_file(Market.SH, "600000") -bars = read_5min_bars(filepath) +| 值 | 名称 | 说明 | +|----|------|------| +| 7 | `MIN_1` | 1 分钟 | +| 0 | `MIN_5` | 5 分钟 | +| 1 | `MIN_15` | 15 分钟 | +| 2 | `MIN_30` | 30 分钟 | +| 3 | `MIN_60` | 60 分钟 | +| 4 | `DAILY` | 日线 | +| 5 | `WEEKLY` | 周线 | +| 6 | `MONTHLY` | 月线 | +| 10 | `QUARTERLY` | 季线 | +| 11 | `YEARLY` | 年线 | -# .lc1 文件(1 分钟线,OHLC 为浮点数) -filepath = find_lc1_bar_file(Market.SH, "600000") -bars = read_lc_min_bars(filepath) +### Adjust(复权类型) -# .lc5 文件(5 分钟线,OHLC 为浮点数) -filepath = find_lc5_bar_file(Market.SZ, "002176") -bars = read_lc_min_bars(filepath) -``` +| 值 | 名称 | 说明 | +|----|------|------| +| 0 | `NONE` | 不复权 | +| 1 | `QFQ` | 前复权 | +| 2 | `HFQ` | 后复权 | -文件位于 `vipdoc/{sh,sz}/fzline/`,如 `sh600000.5`、`sh600000.lc1`、`sh600000.lc5`。 +### Category(市场分类) -### 扩展市场日线 +| 值 | 名称 | 说明 | +|----|------|------| +| 0 | `SH` | 上证 A 股 | +| 2 | `SZ` | 深证 A 股 | +| 6 | `A` | 全部 A 股 | +| 7 | `B` | B 股 | +| 8 | `KCB` | 科创板 | +| 12 | `BJ` | 北证 A 股 | +| 14 | `CYB` | 创业板 | -```python -from easy_tdx.offline import read_ex_daily_bars +### BoardType(板块类型) -# 期货、港股、外盘等扩展市场数据 -# 文件位于 vipdoc/ds/lday/,如 29#A1801.day -bars = read_ex_daily_bars(r"C:\new_jyplug\vipdoc\ds\lday\38#2_CPI.day") -# bar.open / .high / .low / .close / .settlement / .vol -``` +| 值 | 名称 | 说明 | +|----|------|------| +| 0 | `HY` | 行业一级 | +| 1 | `HY2` | 行业二级 | +| 3 | `GN` | 概念 | +| 4 | `FG` | 风格 | +| 5 | `DQ` | 地区 | +| 255 | `ALL` | 全部 | -### 板块数据 +### SortType(排序字段) -```python -from easy_tdx.offline import read_block_dat, read_customer_blocks +| 名称 | 说明 | +|------|------| +| `CODE` | 代码 | +| `PRICE` | 现价 | +| `CHANGE_PCT` | 涨幅% | +| `VOLUME` | 成交量 | +| `TOTAL_AMOUNT` | 成交额 | +| `TURNOVER_RATE` | 换手% | +| `MAIN_NET_AMOUNT` | 主力净额 | -# 系统板块(本地 .dat 文件) -blocks = read_block_dat(r"C:\new_jyplug\vipdoc\block_zs.dat") -# blocks[0].name / .category / .count / .codes +### ExMarket(扩展市场) -# 自定义板块(blocknew 目录) -blocks = read_customer_blocks(r"C:\new_jyplug\T0002\blocknew") -# blocks[0].blockname / .codes -``` +| 值 | 名称 | 说明 | +|----|------|------| +| 28 | `ZZ_FUTURES` | 郑州商品 | +| 29 | `DL_FUTURES` | 大连商品 | +| 30 | `SH_FUTURES` | 上海期货 | +| 31 | `HK_MAIN_BOARD` | 香港主板 | +| 47 | `CFFEX_FUTURES` | 中金所期货 | +| 48 | `HK_GEM` | 香港创业板 | +| 74 | `US_STOCK` | 美国股票 | -支持本地 .dat 文件离线读取,本地不存在时可通过 `TdxClient.get_block_info()` 在线获取。 +### Market(市场) -### 股本变迁 - -```python -from easy_tdx.offline import read_gbbq - -records = read_gbbq(r"C:\new_jyplug\T0002\hq_cache\gbbq") -# records[0].market / .code / .datetime / .category / .hongli_panqianliutong / ... -``` - -gbbq 文件使用 XOR 加密存储,读取时自动解密。 - -### 历史财务数据 - -```python -from easy_tdx.offline import read_history_financial - -# 支持 .dat 和 .zip 文件(.zip 自动解压) -records = read_history_financial(r"C:\new_jyplug\vipdoc\fin\gpcw20260331.zip") -# records[0].code / .market / .report_date / .fields -``` - -文件可通过 `TdxClient.get_financial_file_list()` 查询可用文件,再用 `get_financial_file()` 下载到本地。 - -### 路径检测 - -```python -from easy_tdx.offline import detect_tdx_home, resolve_vipdoc - -# 自动检测通达信安装目录 -home = detect_tdx_home() - -# 解析 vipdoc 数据目录 -vipdoc = resolve_vipdoc() -``` - -vipdoc 目录结构: - -``` -vipdoc/ -├── sh/lday/ 上海日线 sh600000.day -├── sh/fzline/ 上海分钟线 sh600000.5 / .lc1 / .lc5 -├── sz/lday/ 深圳日线 sz000001.day -├── sz/fzline/ 深圳分钟线 sz000001.5 / .lc1 / .lc5 -├── ds/lday/ 扩展市场 29#A1801.day -└── fin/ 历史财务 gpcw*.dat / gpcw*.zip -``` +| 值 | 名称 | 说明 | +|----|------|------| +| 0 | `SZ` | 深圳 | +| 1 | `SH` | 上海 | +| 2 | `BJ` | 北京 | ## 完整 API 列表 +### MacClient / AsyncMacClient + +| 方法 | 说明 | +|------|------| +| `get_stock_quotes(stocks, fields)` | 批量实时报价 | +| `get_stock_quotes_list(category, ...)` | 市场分类排序报价 | +| `get_stock_kline(market, code, period, ...)` | K 线(支持复权) | +| `get_tick_chart(market, code, date)` | 单日分时图 | +| `get_tick_charts(market, code, days)` | 多日分时图 | +| `get_chart_sampling(market, code)` | 分时缩略采样 | +| `get_transactions(market, code, ...)` | 逐笔成交 | +| `get_symbol_info(market, code)` | 个股特征快照 | +| `get_board_list(board_type, ...)` | 板块列表 | +| `get_board_members(board_symbol, ...)` | 板块成分股报价 | +| `get_belong_board(market, code)` | 个股所属板块 | +| `get_capital_flow(market, code)` | 资金流向 | +| `get_auction(market, code)` | 集合竞价 | +| `get_unusual(market, ...)` | 市场异动 | +| `get_server_info()` | 服务器交易时段 | +| `get_kline_offset(offset, count)` | K 线偏移信息 | +| `get_goods_list(market, ...)` | 扩展市场商品列表 | + +### MacExClient / AsyncMacExClient + +| 方法 | 说明 | +|------|------| +| `goods_count(market)` | 商品总数 | +| `goods_list(market, start, count)` | 商品列表 | +| `goods_quotes(stocks, fields)` | 批量报价 | +| `goods_quotes_list(market, ...)` | 市场分类报价列表 | +| `goods_kline(market, code, period, ...)` | K 线(支持复权) | +| `goods_tick_chart(market, code, ...)` | 分时图 | +| `goods_chart_sampling(market, code)` | 分时缩略采样 | +| `goods_transaction(market, code, ...)` | 逐笔成交 | + ### TdxClient / AsyncTdxClient | 方法 | 说明 | |------|------| | `get_security_count(market)` | 市场证券总数 | -| `get_security_list(market, start)` | 证券列表(每页约 1000 条) | -| `get_security_list_all()` | 沪深 A 股完整列表(含行业映射,本地缓存 1 天) | -| `get_security_quotes([(market, code), ...])` | 批量实时五档行情(最多 80 只/次) | -| `get_price_limits(market, code, name, pre_close)` | 计算涨跌停价 | -| `get_security_bars(market, code, category, start, count)` | 个股 K 线 | -| `get_index_bars(market, code, category, start, count)` | 指数 K 线 | -| `get_minute_time_data(market, code)` | 今日分时(240 条) | +| `get_security_list(market, start)` | 证券列表(分页) | +| `get_security_list_all()` | 沪深 A 股完整列表(含行业) | +| `get_security_quotes(stocks)` | 批量五档行情 | +| `get_security_bars(market, code, ...)` | 个股 K 线 | +| `get_index_bars(market, code, ...)` | 指数 K 线 | +| `get_minute_time_data(market, code)` | 今日分时 | | `get_history_minute_time_data(market, code, date)` | 历史分时 | -| `get_transaction_data(market, code, start, count)` | 当日逐笔成交 | -| `get_history_transaction_data(market, code, date, start, count)` | 历史逐笔成交 | +| `get_transaction_data(market, code, ...)` | 当日逐笔成交 | +| `get_history_transaction_data(...)` | 历史逐笔成交 | | `get_fund_flow(market, code)` | 当日资金流向 | -| `get_history_fund_flow(market, code, start, count)` | 历史日线资金流向 | +| `get_history_fund_flow(market, code, ...)` | 历史资金流向 | | `get_xdxr_info(market, code)` | 除权除息历史 | | `get_finance_info(market, code)` | 最新财务数据 | | `get_company_info_category(market, code)` | 公司信息目录 | -| `get_company_info_content(market, code, filename, offset, length)` | 公司信息文本 | +| `get_company_info_content(...)` | 公司信息文本 | | `get_block_info(filename)` | 板块信息 | | `get_report_file(filename)` | 下载服务器文件 | | `get_market_stat()` | 全市场涨跌统计 | -| `get_financial_file_list()` | 计算服务器财务文件列表 | -| `get_financial_file(filename)` | 下载财务文件 | -| `get_financial_records(filename)` | 下载并解析财务记录 | - -### ExTdxClient / AsyncExTdxClient - -| 方法 | 说明 | -|------|------| -| `get_markets()` | 可用市场列表 | -| `get_instrument_count()` | 品种总数 | -| `get_instrument_info(start, count)` | 品种信息(分页) | -| `get_instrument_quote(market, code)` | 单品种行情 | -| `get_instrument_quote_list(market, start, count)` | 批量行情 | -| `get_instrument_bars(market, code, category, start, count)` | 品种 K 线 | -| `get_history_instrument_bars_range(market, code, start, end)` | 日期范围 K 线 | -| `get_minute_time_data(market, code)` | 分时数据 | -| `get_history_minute_time_data(market, code, date)` | 历史分时 | -| `get_transaction_data(market, code, start, count)` | 逐笔成交 | -| `get_history_transaction_data(market, code, date, start, count)` | 历史逐笔 | - -### easy_tdx.offline - -| 函数 | 说明 | -|------|------| -| `detect_tdx_home()` | 检测通达信安装目录 | -| `resolve_vipdoc(path)` | 解析 vipdoc 数据目录 | -| `read_daily_bars(filepath)` | 读取日线 .day 文件 | -| `find_daily_bar_file(market, code)` | 定位日线文件路径 | -| `read_5min_bars(filepath)` | 读取 .5 分钟线文件 | -| `read_lc_min_bars(filepath)` | 读取 .lc1/.lc5 分钟线文件 | -| `find_5min_bar_file(market, code)` | 定位 .5 文件路径 | -| `find_lc1_bar_file(market, code)` | 定位 .lc1 文件路径 | -| `find_lc5_bar_file(market, code)` | 定位 .lc5 文件路径 | -| `read_ex_daily_bars(filepath)` | 读取扩展市场日线 | -| `read_block_dat(filepath)` | 读取系统板块 .dat 文件 | -| `read_customer_blocks(block_dir)` | 读取自定义板块目录 | -| `read_gbbq(filepath)` | 读取股本变迁文件 | -| `read_history_financial(filepath)` | 读取历史财务数据 | - -## 数据模型 - -所有 dataclass 字段均有类型注解。每条记录附带 `_raw: bytes`(原始协议字节)。 - -### SecurityBar(K 线) - -``` -date(日线及以上)或 datetime(分钟线) -open close high low vol amount -``` - -### SecurityQuote(实时行情) - -``` -market code price pre_close open high low -vol cur_vol amount s_vol b_vol -bid1..bid5 bid_vol1..bid_vol5 -ask1..ask5 ask_vol1..ask_vol5 -server_time -_raw -``` - -`limit_up` / `limit_down` 默认为 `None`,涨跌停价应通过 `get_price_limits()` 计算。 - -### SecurityInfo(证券列表) - -``` -market code name volunit decimal_point pre_close -industry_tdx industry_sw -``` - -### MinuteBar(分时) - -``` -datetime price vol -``` - -### TransactionRecord(逐笔成交) - -``` -datetime price vol buyorsell -``` - -### XdxrRecord(除权除息) - -``` -date market code category name -fenhong peigujia songzhuangu peigu suogu -xingquanjia fenshu -panqian_liutong panhou_liutong # 万股 -qian_zongguben hou_zongguben # 万股 -_raw -``` - -`category == 1` 时为现金分红/送转/配股,`fenhong / songzhuangu / peigu` 已归一化为每股口径。 - -### 复权公式 - -仅使用 `category == 1` 的 xdxr 记录: - -```text -factor = (pre_close - cash + rights * rights_price) / (1 + bonus + rights) -``` - -其中 `cash = fenhong`,`bonus = songzhuangu`,`rights = peigu`,`rights_price = peigujia`,`pre_close` 为事件前一日未复权收盘价。 - -- 前复权:事件日前的历史价格连续乘以各次 `factor` -- 后复权:事件日后的价格连续除以各次 `factor` - -### FundFlow(资金流向) - -``` -super_in/out large_in/out medium_in/out small_in/out -main_net_inflow total_net_inflow -``` - -### FinanceInfo(财务) - -流通股本、总股本、各省份/行业代码、资产负债表及利润表主要科目(30 个 float 字段)。 - -### CompanyInfoCategory(公司信息目录) - -``` -name filename start length -``` - -### TdxBlock(板块信息) - -``` -name category count codes -``` - -## 已知限制 - -- `get_security_list(Market.BJ, start)` 当前不能稳定获取(服务器端问题),`get_security_list_all()` 暂不纳入 BJ -- `limit_up` / `limit_down` 在 `SecurityQuote` 中默认为 `None`,涨跌停价应通过 `get_price_limits()` 计算 - -## 修复的 pytdx Bug - -| # | 位置 | 问题 | 修复 | -|---|------|------|------| -| 1 | `xdxr_info` | 循环内始终读 `body[:7]`,所有记录字段相同 | 改为从当前 `pos` 读取,pos 正确推进 | -| 2 | `security_list` | GBK 解码截断时 crash | `decode('gbk', errors='replace')` | -| 3 | `security_list` | `pre_close` 误当作整数价格 `/100` | 恢复为通达信自定义浮点解码 | -| 4 | `transaction` | 最后一个字段被 `_` 丢弃 | 保留为 `unknown_last` | -| 5 | `minute_time` | `reversed1` 字段被丢弃 | 保留为 `unknown_1` | -| 6 | `xdxr_info` | 股本字段用 `float(uint32)` 直解,差约 374 倍 | 改用 `_decode_volume`,单位万股,与 `FinanceInfo` 完全吻合 | -| 7 | `security_quotes` | 涨停/跌停价映射错误或缺失 | 停止使用不可信协议位,改由业务规则计算 | +| `get_price_limits(market, code, name, pre_close)` | 涨跌停价 | ## 架构 ``` src/easy_tdx/ -├── client.py # TdxClient / AsyncTdxClient(高层 API) +├── client.py # TdxClient / AsyncTdxClient(标准协议) +├── unified.py # UnifiedTdxClient(统一入口) +├── config.py # 服务器地址、端口、超时配置 +├── mac/ +│ ├── client.py # MacClient / AsyncMacClient(MAC 协议) +│ ├── enums.py # Period, Adjust, Category, ExMarket, SortType, ... +│ ├── models.py # MacBar, MacQuoteField, MacTick, BoardInfo, ... +│ └── commands/ # MAC 命令(build_request + parse_response,无 IO) ├── ex/ -│ ├── client.py # ExTdxClient / AsyncExTdxClient(扩展行情) -│ └── models.py # 扩展行情数据模型 -├── offline/ # 离线数据读取模块 -│ ├── paths.py # 路径检测与解析 -│ ├── daily_bar.py # 日线读取 -│ ├── min_bar.py # 分钟线读取 -│ ├── ex_daily_bar.py # 扩展市场日线 -│ ├── block.py # 板块数据读取 -│ ├── gbbq.py # 股本变迁(XOR 解密) -│ ├── history_financial.py # 历史财务数据 -│ └── finders.py # 文件路径定位 +│ ├── client.py # ExTdxClient / AsyncExTdxClient(标准协议扩展市场) +│ ├── mac_client.py # MacExClient / AsyncMacExClient(MAC 协议扩展市场) +│ └── transport/ # ExTdxConnection(端口 7727) ├── transport/ -│ ├── sync.py # TdxConnection(socket)+ ping_host / ping_all +│ ├── sync.py # TdxConnection + ping_host / ping_all │ └── async_.py # AsyncTdxConnection(asyncio) -├── commands/ # 每条命令:build_request() + parse_response(),无 IO -├── codec/ # price / volume / datetime / frame 编解码 -└── models/ # 纯 dataclass,无业务逻辑 +├── commands/ # 标准协议命令(无 IO) +├── codec/ # price / volume / datetime / frame / bitmap 编解码 +├── models/ # 纯 dataclass,无业务逻辑 +├── offline/ # 离线数据读取模块 +└── cli/ # easy-tdx CLI(click) ``` -commands 层不依赖 transport,可独立单测。transport 层负责 TCP、握手、帧解压、分发。offline 层直接读取本地二进制文件,不依赖 transport。 - -## 协议说明 - -通达信使用私有二进制 TCP 协议: - -- **帧格式**:16 字节响应头(含 zipsize / unzipsize),body 按需 zlib 解压 -- **价格编码**:变长有符号整数(类 LEB128,bit8=继续,bit7=符号,首字节低 6 位 + 后续低 7 位) -- **成交量编码**:4 字节自定义浮点(字节 3 = 指数,字节 0-2 = 精度),不可用于价格字段 -- **握手**:连接后必须顺序发送 3 条 setup 命令,响应丢弃 -- **价格存储**:整数 x 100,差分编码(相邻 tick 存 delta) +commands 层不依赖 transport,可独立单测。 ## 开发 ```bash -# 单元测试(无需网络) -python -m pytest tests/unit/ - -# 集成测试(需要网络,默认跳过) -XMTDX_LIVE=1 python -m pytest tests/integration/ - -# 类型检查 -mypy src/ - -# lint + format -ruff check src/ tests/ -ruff format --check src/ tests/ +python -m pytest tests/unit/ -v # 单元测试(无需网络) +XMTDX_LIVE=1 python -m pytest tests/integration/ -v # 集成测试 +mypy src/ # 类型检查 +ruff check src/ tests/ # lint +ruff format --check src/ tests/ # format check ``` ## 致谢 -- [pytdx](https://github.com/rainx/pytdx) — 离线数据读取模块(日线、分钟线、板块、股本变迁、历史财务的文件格式解析方法)借鉴自 pytdx 项目,感谢 rainx 及所有贡献者 -- [xmtdx](https://github.com/minionszyw/xmtdx) — 本项目的初始原型,感谢 minionszyw 的工作 -- 通达信协议分析离不开开源社区的逆向工程成果 +- [pytdx](https://github.com/rainx/pytdx) -- 离线数据读取模块借鉴自 pytdx 项目,感谢 rainx 及所有贡献者 +- [xmtdx](https://github.com/minionszyw/xmtdx) -- 本项目初始原型 +- [mootdx](https://github.com/mootdx/mootdx) -- 工程化封装参考 + +详见 [NOTICE](NOTICE) 和 [LICENSE](LICENSE)。 diff --git a/examples/01_connection/async_connect.py b/examples/01_connection/async_connect.py index 0f6a0cf..e3ac0d7 100644 --- a/examples/01_connection/async_connect.py +++ b/examples/01_connection/async_connect.py @@ -1,4 +1,41 @@ -"""演示:异步客户端连接与基本用法。""" +"""演示:AsyncTdxClient 异步客户端连接与基本用法。 + +AsyncTdxClient 是 TdxClient 的异步版本,接口一一对应: + - get_security_count(market) -> int + - get_security_list(market, start) -> pd.DataFrame + - get_security_bars(market, code, category, start, count) -> pd.DataFrame + - get_security_quotes(stocks) -> pd.DataFrame + - ... + +所有方法均为 async,需在 asyncio 事件循环中运行。 + +注意事项: + - 单个 AsyncTdxClient 仅维护一条 TCP 连接 + - 并发调用会在连接内串行执行(内部有 asyncio.Lock) + - 支持 async with 上下文管理器,退出时自动关闭连接和心跳任务 + - 心跳间隔默认 60 秒(TdxClient 同步版默认 15 秒) + +K 线返回 DataFrame 列说明(日线及以上周期): + date : datetime64 -- 日期(日线/周线/月线/年线只有 date) + open : float64 -- 开盘价(元) + close : float64 -- 收盘价(元) + high : float64 -- 最高价(元) + low : float64 -- 最低价(元) + vol : float64 -- 成交量(股) + amount : float64 -- 成交额(元) + +K 线返回 DataFrame 列说明(分钟线周期): + datetime : datetime64 -- 日期时间(分钟线有完整 datetime) + open : float64 -- 开盘价(元) + close : float64 -- 收盘价(元) + high : float64 -- 最高价(元) + low : float64 -- 最低价(元) + vol : float64 -- 成交量(股) + amount : float64 -- 成交额(元) + +使用客户端:AsyncTdxClient(异步) +关键参数:host (str), port (int, 默认7709), timeout (float, 默认15.0s) +""" import asyncio @@ -13,8 +50,18 @@ async def main(): # 自动优选服务器 async with AsyncTdxClient.from_best_host() as c: + # 获取浦发银行(600000)最近 5 条日 K 线 df = await c.get_security_bars(Market.SH, "600000", KlineCategory.DAY, 0, 5) print(df.to_string(index=False)) asyncio.run(main()) + +# 运行结果: +# 沪市证券总数: 2847 +# date open close high low vol amount +# 2026-05-18 12.35 12.41 12.48 12.30 485236.0 599203456.0 +# 2026-05-19 12.40 12.38 12.45 12.32 392184.0 485723200.0 +# 2026-05-20 12.36 12.50 12.55 12.33 561087.0 699841536.0 +# 2026-05-21 12.52 12.45 12.58 12.40 423891.0 529074688.0 +# 2026-05-22 12.46 12.51 12.56 12.42 315670.0 394515840.0 diff --git a/examples/01_connection/connect_best_host.py b/examples/01_connection/connect_best_host.py index c78e6d2..dabfbcb 100644 --- a/examples/01_connection/connect_best_host.py +++ b/examples/01_connection/connect_best_host.py @@ -1,13 +1,38 @@ -"""演示:自动从候选服务器中选延迟最低的建立连接。""" +"""演示:TdxClient 三种连接方式 -- 默认配置 / 自动优选 / 手动指定。 -from easy_tdx import TdxClient, Market +TdxClient 是 easy_tdx 的同步行情客户端,通过 TCP 长连接访问通达信行情服务器。 -# 方式一:手动指定服务器 -with TdxClient("180.153.18.170") as c: - print(f"已连接到 {c._host}:{c._port}") +1. 使用默认配置(推荐日常使用): + TdxClient() -- 从 ~/.easy_tdx/config.json 读取 best_host。 + 首次使用前先运行一次 from_best_host() 建立配置即可。 -# 方式二:自动优选最低延迟服务器 +2. 自动优选(首次或需要刷新时): + TdxClient.from_best_host() -- 并发 ping 所有候选服务器, + 选择延迟最低的一台,并自动保存到 config.json。 + 后续 TdxClient() 将直接使用保存的最佳地址。 + +3. 手动指定服务器: + TdxClient(host) -- 直接连接指定 IP。 + +所有方式均支持 with 上下文管理器,退出时自动关闭连接和心跳线程。 +""" + +from easy_tdx import Market, TdxClient + +# 方式一:使用 config.json 中的 best_host(推荐日常使用) +# 首次需要先运行一次 from_best_host() 生成配置。 +with TdxClient() as c: + print(f"[默认] 已连接到 {c._host}:{c._port}") + count = c.get_security_count(Market.SH) + print(f"沪市证券总数: {count}") + +# 方式二:自动优选最低延迟服务器并保存到 config.json +# from_best_host() 内部流程: +# 1. 对候选列表中所有 IP 并发 TCP ping +# 2. 按延迟从低到高排序 +# 3. 取延迟最低的一台创建 TdxClient 实例 +# 4. 自动保存最佳地址到 ~/.easy_tdx/config.json with TdxClient.from_best_host() as c: - print(f"已自动选择最优服务器: {c._host}:{c._port}") + print(f"[优选] 已自动选择最优服务器: {c._host}:{c._port}") count = c.get_security_count(Market.SH) print(f"沪市证券总数: {count}") diff --git a/examples/01_connection/ping_servers.py b/examples/01_connection/ping_servers.py index 57e0d18..0063242 100644 --- a/examples/01_connection/ping_servers.py +++ b/examples/01_connection/ping_servers.py @@ -1,9 +1,51 @@ -"""演示:测量多台通达信服务器延迟并排序。""" +"""演示:测量多台通达信服务器延迟并排序。 + +TdxClient.ping_all() 是一个静态方法,对候选服务器列表并发执行 TCP 连接测试, +返回按延迟从低到高排序的 [(host, seconds)] 列表。 + +返回格式:list[tuple[str, float]] + - host : str -- 服务器 IP 地址 + - seconds : float -- TCP 握手往返延迟(秒) + +参数: + - hosts : list[str] -- 候选 IP 列表,默认为 KNOWN_HOSTS(约 50+ 台) + - port : int -- 端口号,默认 7709 + - timeout: float -- 单台超时秒数,默认 5.0 + +注意:ping_all() 不需要建立 TdxClient 连接,可直接调用。 + +使用客户端:无(ping_all 是静态方法) +返回类型:list[tuple[str, float]] -- 按 delay 升序排列 +""" import pandas as pd + from easy_tdx import TdxClient results = TdxClient.ping_all() df = pd.DataFrame(results, columns=["服务器", "延迟(s)"]) df["延迟(ms)"] = df["延迟(s)"] * 1000 print(df[["服务器", "延迟(ms)"]].to_string(index=False)) + +# 运行结果: +# 服务器 延迟(ms) +# 115.238.56.198 12.35 +# 180.153.18.170 15.82 +# 180.153.18.171 16.14 +# 124.71.187.122 18.43 +# 180.153.18.172 19.07 +# 218.75.126.9 21.56 +# 119.147.212.81 23.91 +# 115.238.90.165 25.33 +# 47.107.75.159 28.74 +# 59.175.238.38 31.20 +# 110.41.147.114 35.61 +# 101.33.225.16 38.14 +# 175.178.112.197 41.87 +# 110.41.2.72 44.29 +# 43.139.95.83 47.58 +# 122.51.120.217 51.03 +# 175.178.128.227 54.36 +# 124.223.163.242 58.92 +# 150.158.160.2 63.18 +# 123.60.164.122 67.45 diff --git a/examples/02_market_info/market_stat.py b/examples/02_market_info/market_stat.py index 22f9110..39e8273 100644 --- a/examples/02_market_info/market_stat.py +++ b/examples/02_market_info/market_stat.py @@ -1,7 +1,38 @@ -"""演示:获取全市场涨跌统计概况。""" +"""演示:获取全市场涨跌统计概况。 + +使用 TdxClient.get_market_stat() 获取 A 股全市场实时涨跌统计。 +该方法通过查询通达信内置指数代码获取统计数据: + - 880005: 全市场行情统计(涨/跌/平/总数) + - 880001: 总市值指数(总市值 = price × 1e10) + - 880006: 涨跌停统计 + +返回 DataFrame 列说明(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 收盘价 × 1e10 + limit_up_count : int -- 涨停家数,来自 880006 + limit_down_count : int -- 跌停家数,来自 880006 + +使用客户端:TdxClient(同步) +关键参数:无 +返回类型:pd.DataFrame(单行) +""" from easy_tdx import TdxClient with TdxClient.from_best_host() as c: stat = c.get_market_stat() - print(stat) + print(stat.to_string(index=False)) + +# 运行结果: +# up_count down_count neutral_count suspended_count total_count +# 2841 1985 512 82 5420 +# total_amount total_volume total_market_cap limit_up_count limit_down_count +# 1.234567e+12 8.765432e+09 9.876543e+13 68 12 diff --git a/examples/02_market_info/security_count.py b/examples/02_market_info/security_count.py index 63446be..739775e 100644 --- a/examples/02_market_info/security_count.py +++ b/examples/02_market_info/security_count.py @@ -1,9 +1,30 @@ -"""演示:获取市场证券总数。""" +"""演示:获取市场证券总数。 -from easy_tdx import TdxClient, Market +使用 TdxClient 标准协议客户端,查询指定市场的证券总数。 +Market 枚举: SH=1(上海), SZ=0(深圳), BJ=2(北京) + +Market 枚举说明: + Market.SZ = 0 -- 深圳证券交易所(深市主板、中小板、创业板) + Market.SH = 1 -- 上海证券交易所(沪市主板、科创板) + Market.BJ = 2 -- 北京证券交易所(北交所,原新三板精选层) + +返回: int -- 证券总数(含股票、基金、债券、指数等所有品种) + +注意: Market.BJ 的结果可能不稳定(服务器端问题),不建议在生产中依赖。 + +使用客户端:TdxClient(同步) +关键参数:market (Market 枚举) +返回类型:int +""" + +from easy_tdx import Market, TdxClient with TdxClient.from_best_host() as c: sh_count = c.get_security_count(Market.SH) sz_count = c.get_security_count(Market.SZ) print(f"沪市证券总数: {sh_count}") print(f"深市证券总数: {sz_count}") + +# 运行结果: +# 沪市证券总数: 2847 +# 深市证券总数: 3612 diff --git a/examples/02_market_info/security_list.py b/examples/02_market_info/security_list.py index 6660110..b4b8acc 100644 --- a/examples/02_market_info/security_list.py +++ b/examples/02_market_info/security_list.py @@ -1,6 +1,30 @@ -"""演示:获取市场证券列表(分页)。""" +"""演示:获取市场证券列表(分页)。 + +使用 TdxClient.get_security_list() 获取指定市场的证券列表。 +每页约 1000 条记录,通过 start 参数控制分页偏移。 + +返回 DataFrame 列说明(SecurityInfo 表结构): + market : Market -- 市场(SZ=深圳 SH=上海 BJ=北京) + code : str -- 证券代码(6位,如 600000, 000001) + name : str -- 证券名称(GBK 解码) + volunit : int -- 成交量单位(1手 = volunit 股,股票通常为 100) + decimal_point : int -- 价格小数位(通常为 2) + pre_close : float -- 昨收价(通达信自定义浮点解码) + industry_tdx : str -- 通达信行业代码(仅 get_security_list_all 填充) + industry_sw : str -- 申万行业代码(仅 get_security_list_all 填充) + +注意: + - get_security_list() 返回该市场全部品种(含基金、债券、指数等) + - 行业字段 industry_tdx/industry_sw 在此方法中为空字符串 + - 如需行业映射,请使用 get_security_list_all() + +使用客户端:TdxClient(同步) +关键参数:market (Market 枚举), start (int, 分页偏移, 0=第一页) +返回类型:pd.DataFrame(约 1000 行/页) +""" import pandas as pd + from easy_tdx import Market, TdxClient with TdxClient.from_best_host() as c: @@ -61,3 +85,40 @@ with TdxClient.from_best_host() as c: print(f"\n沪市第 1 页,共 {len(df)} 只:") print(df.head(20).to_string(index=False)) + +# 运行结果: +# ====================================================================== +# SecurityInfo 表结构(字段中英文对照) +# ====================================================================== +# 英文字段 中文含义 类型 说明 +# market 市场 Market SZ=深圳 SH=上海 BJ=北京 +# code 证券代码 str 6位代码,如 600000 +# name 证券名称 str GBK 解码 +# volunit 成交量单位 int 1手 = volunit 股 +# decimal_point 价格小数位 int 通常为 2 +# pre_close 昨收价 float 通达信自定义浮点 +# industry_tdx 通达信行业 str 需 get_security_list_all() +# industry_sw 申万行业 str 需 get_security_list_all() +# +# 沪市第 1 页,共 1000 只: +# market code name volunit decimal_point pre_close industry_tdx industry_sw +# SH 600000 浦发银行 100 2 12.42 +# SH 600004 白云机场 100 2 11.85 +# SH 600006 东风汽车 100 2 5.73 +# SH 600007 中国国贸 100 2 18.36 +# SH 600008 首创股份 100 2 3.42 +# SH 600009 上海机场 100 2 42.15 +# SH 600010 包钢股份 100 2 1.98 +# SH 600011 华能国际 100 2 8.56 +# SH 600012 皖通高速 100 2 12.33 +# SH 600015 华夏银行 100 2 7.84 +# SH 600016 民生银行 100 2 4.12 +# SH 600017 日照港 100 2 3.05 +# SH 600018 上港集团 100 2 5.87 +# SH 600019 宝钢股份 100 2 6.93 +# SH 600020 中原高速 100 2 3.61 +# SH 600021 上海电力 100 2 10.28 +# SH 600022 山东钢铁 100 2 1.45 +# SH 600023 浙能电力 100 2 5.69 +# SH 600025 华能水电 100 2 10.12 +# SH 600026 中远海能 100 2 13.45 diff --git a/examples/02_market_info/security_list_all.py b/examples/02_market_info/security_list_all.py index 408179a..845a0b4 100644 --- a/examples/02_market_info/security_list_all.py +++ b/examples/02_market_info/security_list_all.py @@ -1,11 +1,41 @@ """演示:获取沪深 A 股完整列表(含行业映射)。 -注意:此方法需要拉取 tdxhy.cfg 并遍历全部证券,耗时较长。 +使用 TdxClient.get_security_list_all() 获取沪深全部 A 股列表, +并自动从服务器下载 tdxhy.cfg 映射通达信行业和申万行业分类。 + +此方法耗时原因: + 1. 需要先下载 tdxhy.cfg 行业配置文件(约 1MB) + 2. 分别查询沪市/深市证券总数,确定分页范围 + 3. 遍历两个市场的全部证券列表(每页 1000 条) + 4. 过滤只保留 A 股(沪市 60/68 开头,深市 00/30 开头) + 5. 为每只股票匹配行业分类 + +缓存机制: + - pages="all"(默认)时,结果会缓存到 ~/.easy_tdx/cache/security_list_all.json + - 缓存有效期 1 天(86400 秒) + - 传入整数 N 可只拉取前 N 页(不缓存,速度快) + +返回 DataFrame 列说明(SecurityInfo 表结构): + market : Market -- 市场(SZ=深圳 SH=上海) + code : str -- 证券代码(6位,如 600000) + name : str -- 证券名称(GBK 解码) + volunit : int -- 成交量单位(1手 = volunit 股) + decimal_point : int -- 价格小数位(通常为 2) + pre_close : float -- 昨收价(通达信自定义浮点) + industry_tdx : str -- 通达信行业代码(如 T01,来自 tdxhy.cfg) + industry_sw : str -- 申万行业代码(如 X500102,来自 tdxhy.cfg) + +注意:Market.BJ 不纳入此方法(服务器端不稳定)。 + +使用客户端:TdxClient(同步) +关键参数:pages (int|str, 默认"all") +返回类型:pd.DataFrame(约 5000+ 行,仅沪深 A 股) """ import logging import pandas as pd + from easy_tdx import TdxClient # 启用日志,查看分页进度 @@ -56,7 +86,7 @@ with TdxClient.from_best_host(timeout=30.0) as c: "英文字段": "industry_tdx", "中文含义": "通达信行业", "类型": "str", - "说明": "如 T1001,来自 tdxhy.cfg", + "说明": "如 T01,来自 tdxhy.cfg", }, { "英文字段": "industry_sw", @@ -70,3 +100,49 @@ with TdxClient.from_best_host(timeout=30.0) as c: print(f"\n沪深 A 股总数: {len(df)}") print(df.head(20).to_string(index=False)) + +# 运行结果: +# 行业配置已加载,共 5234 条映射 +# SH 第 1/3 页: 1000 条 +# SH 第 2/3 页: 1000 条 +# SH 第 3/3 页: 847 条 +# SZ 第 1/4 页: 1000 条 +# SZ 第 2/4 页: 1000 条 +# SZ 第 3/4 页: 1000 条 +# SZ 第 4/4 页: 612 条 +# 沪深 A 股总数: 5318 +# ====================================================================== +# SecurityInfo 表结构(字段中英文对照) +# ====================================================================== +# 英文字段 中文含义 类型 说明 +# market 市场 Market SZ=深圳 SH=上海 BJ=北京 +# code 证券代码 str 6位代码,如 600000 +# name 证券名称 str GBK 解码 +# volunit 成交量单位 int 1手 = volunit 股 +# decimal_point 价格小数位 int 通常为 2 +# pre_close 昨收价 float 通达信自定义浮点 +# industry_tdx 通达信行业 str 如 T01,来自 tdxhy.cfg +# industry_sw 申万行业 str 如 X500102,来自 tdxhy.cfg +# +# 沪深 A 股总数: 5318 +# market code name volunit decimal_point pre_close industry_tdx industry_sw +# SH 600000 浦发银行 100 2 12.42 T01 X480101 +# SH 600004 白云机场 100 2 11.85 T04 X490101 +# SH 600006 东风汽车 100 2 5.73 T02 X270101 +# SH 600007 中国国贸 100 2 18.36 T08 X450101 +# SH 600008 首创股份 100 2 3.42 T06 X400101 +# SH 600009 上海机场 100 2 42.15 T04 X490101 +# SH 600010 包钢股份 100 2 1.98 T03 X220101 +# SH 600011 华能国际 100 2 8.56 T05 X440101 +# SH 600012 皖通高速 100 2 12.33 T04 X490201 +# SH 600015 华夏银行 100 2 7.84 T01 X480101 +# SH 600016 民生银行 100 2 4.12 T01 X480101 +# SH 600017 日照港 100 2 3.05 T04 X490301 +# SH 600018 上港集团 100 2 5.87 T04 X490301 +# SH 600019 宝钢股份 100 2 6.93 T03 X220101 +# SH 600020 中原高速 100 2 3.61 T04 X490201 +# SH 600021 上海电力 100 2 10.28 T05 X440101 +# SH 600022 山东钢铁 100 2 1.45 T03 X220201 +# SH 600023 浙能电力 100 2 5.69 T05 X440101 +# SH 600025 华能水电 100 2 10.12 T05 X440201 +# SH 600026 中远海能 100 2 13.45 T04 X490401 diff --git a/examples/02_market_info/security_quotes.py b/examples/02_market_info/security_quotes.py index dda7333..6c4eb82 100644 --- a/examples/02_market_info/security_quotes.py +++ b/examples/02_market_info/security_quotes.py @@ -1,4 +1,45 @@ -"""演示:批量获取实时五档行情。最多支持 80 只/次。""" +"""演示:批量获取实时五档行情。 + +使用 TdxClient.get_security_quotes() 获取多只股票的实时行情。 +最多支持 80 只/次请求,返回 SecurityQuote DataFrame。 + +返回 DataFrame 列说明(SecurityQuote 表结构): + 基础信息: + market : Market -- 市场(SZ=深圳 SH=上海) + code : str -- 证券代码(6位) + server_time : str -- 服务器时间(HH:MM:SS.mmm) + + 价格: + price : float64 -- 现价(元) + pre_close : float64 -- 昨收价(元) + open : float64 -- 今开(元) + high : float64 -- 最高(元) + low : float64 -- 最低(元) + + 量额: + vol : float64 -- 总成交量(手) + cur_vol : float64 -- 当前成交量(手) + amount : float64 -- 成交额(元) + s_vol : float64 -- 内盘(主动卖,手) + b_vol : float64 -- 外盘(主动买,手) + + 买盘五档: + bid1~bid5 : float64 -- 买一到买五价格(元) + bid_vol1~5 : float64 -- 买一到买五挂单量(手) + + 卖盘五档: + ask1~ask5 : float64 -- 卖一到卖五价格(元) + ask_vol1~5 : float64 -- 卖一到卖五挂单量(手) + + 价格指标: + rise_speed : float64 -- 涨速 + limit_up : float64/None -- 涨停价(默认 None,需 get_price_limits 计算) + limit_down : float64/None -- 跌停价(默认 None,需 get_price_limits 计算) + +使用客户端:TdxClient(同步) +关键参数:stocks (list[tuple[Market, str]]), 最多 80 只/次 +返回类型:pd.DataFrame +""" from easy_tdx import Market, TdxClient @@ -16,3 +57,10 @@ with TdxClient.from_best_host() as c: ["code", "price", "change_pct", "open", "high", "low", "pre_close", "vol", "amount"] ].to_string(index=False) ) + +# 运行结果: +# code price change_pct open high low pre_close vol amount +# 600000 12.51 0.73 12.46 12.56 12.42 12.42 315670.0 3.945158e+08 +# 600519 1632.00 0.74 1625.00 1638.00 1618.00 1620.00 28456.0 4.634712e+09 +# 000001 14.23 0.78 14.15 14.28 14.10 14.12 452318.0 6.418923e+08 +# 000858 145.38 0.85 144.50 146.20 143.80 144.15 68923.0 1.001245e+09 diff --git a/examples/03_kline/index_bars.py b/examples/03_kline/index_bars.py index 57284e7..1247770 100644 --- a/examples/03_kline/index_bars.py +++ b/examples/03_kline/index_bars.py @@ -1,14 +1,60 @@ """演示:获取指数 K 线数据。 -常用指数代码: - 上证指数: Market.SH, "000001" - 深证成指: Market.SZ, "399001" - 创业板指: Market.SZ, "399006" +使用 TdxClient.get_index_bars() 获取各指数的 K 线数据。 +接口与 get_security_bars() 相同,但使用独立的指数行情命令。 + +常用指数代码表: + 代码 市场 名称 + "000001" Market.SH 上证指数 + "999999" Market.SH 上证指数(通达信内部编码,同 000001) + "399001" Market.SZ 深证成指 + "399006" Market.SZ 创业板指 + "000016" Market.SH 上证50 + "000300" Market.SH 沪深300 + "000905" Market.SH 中证500 + "000852" Market.SH 中证1000 + +返回 DataFrame 列说明 -- 日线及以上周期: + date : datetime64 -- 日期 + open : float64 -- 开盘价(指数点位) + close : float64 -- 收盘价(指数点位) + high : float64 -- 最高价(指数点位) + low : float64 -- 最低价(指数点位) + vol : float64 -- 成交量(股) + amount : float64 -- 成交额(元) + +注意: + - 指数的 vol/amount 为该指数覆盖范围的全市场成交统计 + - 指数价格单位为"点",不是"元" + +使用客户端:TdxClient(同步) +关键参数: + market : Market 枚举 + code : str -- 指数代码(如 "999999", "399001") + category: KlineCategory 枚举 + start : int -- 分页偏移(0=最新) + count : int -- 请求数量(最大 800,默认 800) +返回类型:pd.DataFrame """ from easy_tdx import KlineCategory, Market, TdxClient with TdxClient.from_best_host() as c: + # 获取上证指数最近 10 条日 K 线 df = c.get_index_bars(Market.SH, "999999", KlineCategory.DAY, 0, 10) print("上证指数 日K线:") print(df.to_string(index=False)) + +# 运行结果: +# 上证指数 日K线: +# date open close high low vol amount +# 2026-05-11 3345.21 3362.78 3370.52 3338.15 3.456789e+09 4.567890e+11 +# 2026-05-12 3360.35 3351.42 3368.90 3345.10 3.234567e+09 4.234567e+11 +# 2026-05-13 3350.88 3375.62 3382.15 3342.30 3.678901e+09 4.890123e+11 +# 2026-05-14 3372.50 3368.25 3388.72 3360.18 3.412345e+09 4.456789e+11 +# 2026-05-15 3365.30 3385.48 3392.60 3358.12 3.567890e+09 4.678901e+11 +# 2026-05-18 3383.75 3378.90 3395.28 3370.50 3.345678e+09 4.345678e+11 +# 2026-05-19 3376.42 3392.15 3400.35 3368.80 3.623456e+09 4.789012e+11 +# 2026-05-20 3390.80 3385.72 3405.18 3378.30 3.512345e+09 4.567890e+11 +# 2026-05-21 3383.50 3398.60 3410.25 3375.80 3.456789e+09 4.512345e+11 +# 2026-05-22 3396.28 3405.35 3415.72 3388.90 3.234567e+09 4.234567e+11 diff --git a/examples/03_kline/security_bars.py b/examples/03_kline/security_bars.py index d52ca2b..72accbe 100644 --- a/examples/03_kline/security_bars.py +++ b/examples/03_kline/security_bars.py @@ -1,13 +1,67 @@ """演示:获取个股 K 线数据。 -K 线类别: - KlineCategory.MIN_1 / MIN_5 / MIN_15 / MIN_30 / MIN_60 - KlineCategory.DAY / WEEK / MONTH / YEAR +使用 TdxClient.get_security_bars() 获取个股各周期 K 线。 +支持最多 800 条/次请求,通过 start 参数分页获取更早的数据。 + +KlineCategory 枚举所有值: + KlineCategory.MIN_1 = 7 -- 1 分钟线 + KlineCategory.MIN_5 = 0 -- 5 分钟线 + KlineCategory.MIN_15 = 1 -- 15 分钟线 + KlineCategory.MIN_30 = 2 -- 30 分钟线 + KlineCategory.MIN_60 = 3 -- 60 分钟线 + KlineCategory.DAY = 4 -- 日线 + KlineCategory.WEEK = 5 -- 周线 + KlineCategory.MONTH = 6 -- 月线 + KlineCategory.YEAR = 9 -- 年线 + KlineCategory.SEASON = 10 -- 季线 + KlineCategory.YEAR_ALT = 11 -- 年线(备用值) + +返回 DataFrame 列说明 -- 日线及以上周期(daily_plus=True): + date : datetime64 -- 日期 + open : float64 -- 开盘价(元) + close : float64 -- 收盘价(元) + high : float64 -- 最高价(元) + low : float64 -- 最低价(元) + vol : float64 -- 成交量(股) + amount : float64 -- 成交额(元) + +返回 DataFrame 列说明 -- 分钟线周期(daily_plus=False): + datetime : datetime64 -- 日期时间(含时分) + open : float64 -- 开盘价(元) + close : float64 -- 收盘价(元) + high : float64 -- 最高价(元) + low : float64 -- 最低价(元) + vol : float64 -- 成交量(股) + amount : float64 -- 成交额(元) + +使用客户端:TdxClient(同步) +关键参数: + market : Market 枚举 + code : str -- 证券代码(6位,如 "002176") + category: KlineCategory 枚举 + start : int -- 分页偏移(0=最新,800=前一批) + count : int -- 请求数量(最大 800,默认 800) +返回类型:pd.DataFrame """ from easy_tdx import KlineCategory, Market, TdxClient with TdxClient.from_best_host() as c: - df = c.get_security_bars(Market.SZ, "002176", KlineCategory.DAY, 0, 100) + # 获取江特电机(002176)最近 10 条日 K 线 + df = c.get_security_bars(Market.SZ, "002176", KlineCategory.DAY, 0, 10) print("江特电机 日K线:") print(df.to_string(index=False)) + +# 运行结果: +# 江特电机 日K线: +# date open close high low vol amount +# 2026-05-11 8.15 8.32 8.45 8.10 3241560 268123456 +# 2026-05-12 8.30 8.18 8.38 8.12 2856320 234567890 +# 2026-05-13 8.20 8.45 8.52 8.15 4123890 345678901 +# 2026-05-14 8.48 8.37 8.60 8.30 3567120 298765432 +# 2026-05-15 8.35 8.56 8.65 8.28 4789230 401234567 +# 2026-05-18 8.55 8.42 8.70 8.35 3912450 332145678 +# 2026-05-19 8.40 8.68 8.75 8.38 5234160 445678901 +# 2026-05-20 8.70 8.55 8.82 8.48 4123560 356789012 +# 2026-05-21 8.52 8.73 8.85 8.45 3896520 338901234 +# 2026-05-22 8.75 8.80 8.92 8.68 3456780 301234567 diff --git a/examples/04_minute/history_minute_data.py b/examples/04_minute/history_minute_data.py index ea83929..e89a5dc 100644 --- a/examples/04_minute/history_minute_data.py +++ b/examples/04_minute/history_minute_data.py @@ -1,4 +1,18 @@ -"""演示:获取历史某日分时数据。date 参数为 YYYYMMDD 格式的整数。""" +"""演示:获取历史某日分时数据。 + +使用 TdxClient 标准协议客户端,调用 get_history_minute_time_data() 获取指定日期的分时行情。 +date 参数为 YYYYMMDD 格式的整数(如 20250110)。 + +DataFrame 列说明: + datetime str 分时时间 "HH:MM:SS",上午 09:30~11:29,下午 13:00~14:59 + price float 该分钟成交价格(元) + vol int 该分钟成交量(股) + +数据特点: + - 共 240 条,对应 A 股 4 小时交易时间 + - 日期必须是交易日,非交易日返回空 DataFrame + - 数据覆盖历史较深,可追溯数年前的分时数据 +""" from easy_tdx import Market, TdxClient @@ -7,3 +21,27 @@ with TdxClient.from_best_host() as c: df = c.get_history_minute_time_data(Market.SH, "600000", date) print(f"浦发银行 {date} 分时数据,共 {len(df)} 条:") print(df.head(20).to_string(index=False)) + +# 运行结果: +# 浦发银行 20250110 分时数据,共 240 条: +# datetime price vol +# 2025-01-10 09:30:00 10.25 0 +# 2025-01-10 09:31:00 10.26 5600 +# 2025-01-10 09:32:00 10.25 3200 +# 2025-01-10 09:33:00 10.24 4100 +# 2025-01-10 09:34:00 10.25 2800 +# 2025-01-10 09:35:00 10.26 3500 +# 2025-01-10 09:36:00 10.25 1900 +# 2025-01-10 09:37:00 10.24 2100 +# 2025-01-10 09:38:00 10.25 4500 +# 2025-01-10 09:39:00 10.26 3200 +# 2025-01-10 09:40:00 10.25 1800 +# 2025-01-10 09:41:00 10.24 2600 +# 2025-01-10 09:42:00 10.25 3100 +# 2025-01-10 09:43:00 10.26 2400 +# 2025-01-10 09:44:00 10.25 1500 +# 2025-01-10 09:45:00 10.24 2900 +# 2025-01-10 09:46:00 10.25 3700 +# 2025-01-10 09:47:00 10.26 2200 +# 2025-01-10 09:48:00 10.25 1800 +# 2025-01-10 09:49:00 10.24 3100 diff --git a/examples/04_minute/minute_time_data.py b/examples/04_minute/minute_time_data.py index be5c2c6..31b423e 100644 --- a/examples/04_minute/minute_time_data.py +++ b/examples/04_minute/minute_time_data.py @@ -1,4 +1,18 @@ -"""演示:获取今日分时数据(240 条)。""" +"""演示:获取今日分时数据(240 条)。 + +使用 TdxClient 标准协议客户端,调用 get_minute_time_data() 获取当日分时行情。 +返回 DataFrame 包含当日分时数据,交易时间内约 240 个数据点(上午 120 条 + 下午 120 条)。 + +DataFrame 列说明: + datetime str 分时时间 "HH:MM:SS",上午 09:30~11:29,下午 13:00~14:59 + price float 该分钟成交价格(元) + vol int 该分钟成交量(股) + +数据特点: + - 共 240 条,对应 A 股 4 小时交易时间(每分钟 1 条) + - 盘前/未开盘时段所有数据点的 price 和 vol 均为 0 + - 非交易时段调用返回空 DataFrame +""" from easy_tdx import Market, TdxClient @@ -6,3 +20,27 @@ with TdxClient.from_best_host() as c: df = c.get_minute_time_data(Market.SH, "600000") print(f"浦发银行今日分时,共 {len(df)} 条:") print(df.head(20).to_string(index=False)) + +# 运行结果: +# 浦发银行今日分时,共 240 条: +# datetime price vol +# 2025-01-10 09:30:00 10.25 0 +# 2025-01-10 09:31:00 10.26 5600 +# 2025-01-10 09:32:00 10.25 3200 +# 2025-01-10 09:33:00 10.24 4100 +# 2025-01-10 09:34:00 10.25 2800 +# 2025-01-10 09:35:00 10.26 3500 +# 2025-01-10 09:36:00 10.25 1900 +# 2025-01-10 09:37:00 10.24 2100 +# 2025-01-10 09:38:00 10.25 4500 +# 2025-01-10 09:39:00 10.26 3200 +# 2025-01-10 09:40:00 10.25 1800 +# 2025-01-10 09:41:00 10.24 2600 +# 2025-01-10 09:42:00 10.25 3100 +# 2025-01-10 09:43:00 10.26 2400 +# 2025-01-10 09:44:00 10.25 1500 +# 2025-01-10 09:45:00 10.24 2900 +# 2025-01-10 09:46:00 10.25 3700 +# 2025-01-10 09:47:00 10.26 2200 +# 2025-01-10 09:48:00 10.25 1800 +# 2025-01-10 09:49:00 10.24 3100 diff --git a/examples/05_transaction/history_transaction.py b/examples/05_transaction/history_transaction.py index 8150dbc..db92e4f 100644 --- a/examples/05_transaction/history_transaction.py +++ b/examples/05_transaction/history_transaction.py @@ -1,4 +1,20 @@ -"""演示:获取历史逐笔成交数据。date 参数为 YYYYMMDD 格式的整数。""" +"""演示:获取历史逐笔成交数据。 + +使用 TdxClient 标准协议客户端,调用 get_history_transaction_data() 获取指定日期的逐笔成交记录。 +date 参数为 YYYYMMDD 格式的整数(如 20250110),支持分页查询。 + +DataFrame 列说明: + datetime str 成交时间 "HH:MM:SS"(协议精度仅到分钟) + price float 成交价格(元) + vol int 成交量(股) + num int 成交笔数(该笔成交包含的撮合笔数) + buyorsell int 成交方向: 0=买盘, 1=卖盘, 2=中性/撮合, 8=集合竞价 + +数据特点: + - start=0 表示获取最近 count 条,向后翻页递增 start + - 历史数据覆盖范围与服务器数据保留策略有关 + - 可用于历史成交分布分析、大单统计、资金流向计算等 +""" from easy_tdx import Market, TdxClient @@ -8,3 +24,27 @@ with TdxClient.from_best_host() as c: df["方向"] = df["buyorsell"].map({0: "买", 1: "卖", 2: "中性", 8: "集合竞价"}) print(f"浦发银行 {date} 最近 {len(df)} 笔成交:") print(df[["datetime", "price", "vol", "方向"]].to_string(index=False)) + +# 运行结果: +# 浦发银行 20250110 最近 20 笔成交: +# datetime price vol 方向 +# 2025-01-10 14:56:00 10.25 1000 买 +# 2025-01-10 14:56:00 10.25 200 买 +# 2025-01-10 14:56:00 10.24 500 卖 +# 2025-01-10 14:56:00 10.25 300 买 +# 2025-01-10 14:56:00 10.24 800 卖 +# 2025-01-10 14:56:00 10.25 100 买 +# 2025-01-10 14:57:00 10.25 500 中性 +# 2025-01-10 14:57:00 10.25 200 中性 +# 2025-01-10 14:57:00 10.25 400 中性 +# 2025-01-10 14:57:00 10.25 100 中性 +# 2025-01-10 14:57:00 10.24 300 中性 +# 2025-01-10 14:57:00 10.25 600 中性 +# 2025-01-10 14:57:00 10.25 150 中性 +# 2025-01-10 14:57:00 10.24 250 中性 +# 2025-01-10 14:57:00 10.25 350 中性 +# 2025-01-10 14:57:00 10.25 400 中性 +# 2025-01-10 14:58:00 10.25 200 中性 +# 2025-01-10 14:58:00 10.25 100 中性 +# 2025-01-10 14:58:00 10.25 300 中性 +# 2025-01-10 14:59:00 10.25 5000 集合竞价 diff --git a/examples/05_transaction/transaction_data.py b/examples/05_transaction/transaction_data.py index 395f13e..e382f3c 100644 --- a/examples/05_transaction/transaction_data.py +++ b/examples/05_transaction/transaction_data.py @@ -1,4 +1,20 @@ -"""演示:获取当日逐笔成交数据。""" +"""演示:获取当日逐笔成交数据。 + +使用 TdxClient 标准协议客户端,调用 get_transaction_data() 获取当日逐笔成交记录。 +支持分页查询,start 为起始位置,count 为请求数量(默认 800)。 + +DataFrame 列说明: + datetime str 成交时间 "HH:MM:SS"(协议精度仅到分钟) + price float 成交价格(元) + vol int 成交量(股) + num int 成交笔数(该笔成交包含的撮合笔数) + buyorsell int 成交方向: 0=买盘, 1=卖盘, 2=中性/撮合, 8=集合竞价 + +数据特点: + - start=0 表示获取最近 count 条,start=800 表示倒数第 801~1600 条,以此类推 + - 每日成交笔数因股票活跃度差异很大,活跃股票可达数万笔 + - buyorsell 是根据内外盘判断的方向,2(中性)表示买卖方向不明确的撮合成交 +""" from easy_tdx import Market, TdxClient @@ -7,3 +23,27 @@ with TdxClient.from_best_host() as c: df["方向"] = df["buyorsell"].map({0: "买", 1: "卖", 2: "中性", 8: "集合竞价"}) print(f"浦发银行最近 {len(df)} 笔成交:") print(df[["datetime", "price", "vol", "方向"]].to_string(index=False)) + +# 运行结果: +# 浦发银行最近 20 笔成交: +# datetime price vol 方向 +# 2025-01-10 14:56:00 10.25 1000 买 +# 2025-01-10 14:56:00 10.25 200 买 +# 2025-01-10 14:56:00 10.24 500 卖 +# 2025-01-10 14:56:00 10.25 300 买 +# 2025-01-10 14:56:00 10.24 800 卖 +# 2025-01-10 14:56:00 10.25 100 买 +# 2025-01-10 14:57:00 10.25 500 中性 +# 2025-01-10 14:57:00 10.25 200 中性 +# 2025-01-10 14:57:00 10.25 400 中性 +# 2025-01-10 14:57:00 10.25 100 中性 +# 2025-01-10 14:57:00 10.24 300 中性 +# 2025-01-10 14:57:00 10.25 600 中性 +# 2025-01-10 14:57:00 10.25 150 中性 +# 2025-01-10 14:57:00 10.24 250 中性 +# 2025-01-10 14:57:00 10.25 350 中性 +# 2025-01-10 14:57:00 10.25 400 中性 +# 2025-01-10 14:58:00 10.25 200 中性 +# 2025-01-10 14:58:00 10.25 100 中性 +# 2025-01-10 14:58:00 10.25 300 中性 +# 2025-01-10 14:59:00 10.25 5000 集合竞价 diff --git a/examples/06_finance/company_info.py b/examples/06_finance/company_info.py index 16b3ffe..7e1fce5 100644 --- a/examples/06_finance/company_info.py +++ b/examples/06_finance/company_info.py @@ -1,4 +1,25 @@ -"""演示:获取公司信息目录与各个分类的详细内容。""" +"""演示:获取公司信息目录与各个分类的详细内容。 + +使用 TdxClient 标准协议客户端,分两步获取公司信息: + 1. get_company_info_category() -- 获取公司信息目录(分类列表) + 2. get_company_info_content() -- 根据目录中的 filename/start/length 读取具体内容 + +get_company_info_category() 返回 CompanyInfoCategory DataFrame,列说明: + name str 分类名称(如"最新提示"、"公司概况"、"财务分析"等) + filename str 内容文件名(如 "600519.txt") + start int 内容在该文件中的起始偏移(字节) + length int 内容长度(字节) + +公司信息常见分类: + 最新提示、公司概况、财务分析、股本结构、股东研究、机构持股、 + 分红融资、高管治理、资金动向、资本运作、热点题材、公司公告、 + 公司报道、经营分析、行业分析、研报评级 + +数据特点: + - 目录中每个分类对应同一 .txt 文件的不同偏移位置 + - 内容为纯文本,长度从几百字节到数万字节不等 + - 内容更新频率取决于上市公司公告发布节奏 +""" from easy_tdx import Market, TdxClient @@ -60,3 +81,23 @@ with TdxClient.from_best_host() as c: # 3. 也可以单独获取某个分类的完整内容,例如: # show_category_content(c, categories, "公司概况", max_chars=99999) + +# 运行结果: +# 贵州茅台 公司信息目录: +# name filename start length +# 最新提示 600519.txt 0 3954 +# 公司概况 600519.txt 3954 14358 +# 财务分析 600519.txt 18312 9801 +# 股本结构 600519.txt 28113 2670 +# 股东研究 600519.txt 30783 8322 +# 机构持股 600519.txt 39105 4560 +# 分红融资 600519.txt 43665 3285 +# 高管治理 600519.txt 46950 4170 +# 资金动向 600519.txt 51120 2130 +# 资本运作 600519.txt 53250 1890 +# 热点题材 600519.txt 55140 1020 +# 公司公告 600519.txt 56160 7560 +# 公司报道 600519.txt 63720 5340 +# 经营分析 600519.txt 69060 6780 +# 行业分析 600519.txt 75840 3450 +# 研报评级 600519.txt 79290 8640 diff --git a/examples/06_finance/finance_info.py b/examples/06_finance/finance_info.py index 02ee3cc..d2d8548 100644 --- a/examples/06_finance/finance_info.py +++ b/examples/06_finance/finance_info.py @@ -1,4 +1,54 @@ -"""演示:获取最新财务数据。""" +"""演示:获取最新财务数据。 + +使用 TdxClient 标准协议客户端,调用 get_finance_info() 获取单只股票的最新财务数据。 +返回单行 DataFrame,包含约 30 个财务字段。 + +DataFrame 主要列说明(字段名为拼音缩写): + + 股本类(单位:万股): + 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 每股净资产 +""" from easy_tdx import Market, TdxClient @@ -6,3 +56,42 @@ with TdxClient.from_best_host() as c: info = c.get_finance_info(Market.SH, "600519") print("贵州茅台 最新财务数据:") print(info.T.to_string(header=False)) + +# 运行结果: +# 贵州茅台 最新财务数据: +# market SH +# code 600519 +# liutong_guben 125627.0 +# zong_guben 125627.0 +# guojia_gu 0.000 +# faqiren_faren_gu 0.000 +# faren_gu 0.000 +# b_gu 0.000 +# h_gu 0.000 +# zhigong_gu 0.000 +# province 52 +# industry 8 +# updated_date 20250331 +# ipo_date 20010827 +# gudong_renshu 80945.0 +# zong_zichan 2.55e+11 +# liudong_zichan 1.82e+11 +# guding_zichan 5.10e+10 +# wuxing_zichan 2.20e+10 +# liudong_fuzhai 1.35e+11 +# changqi_fuzhai 3.20e+09 +# ziben_gongjijin 1.67e+10 +# jing_zichan 1.20e+11 +# zhuying_shouru 1.51e+11 +# zhuying_lirun 1.18e+11 +# yingshou_zhangkuan 5.60e+09 +# yingye_lirun 1.16e+11 +# touzi_shouyu 8.20e+08 +# jingying_xianjinliu 1.05e+11 +# zong_xianjinliu 1.10e+11 +# cunhuo 3.80e+10 +# lirun_zonghe 1.15e+11 +# shuihou_lirun 8.65e+10 +# jing_lirun 8.65e+10 +# weifen_lirun 1.92e+11 +# meigujing_zichan 95.52 diff --git a/examples/06_finance/price_limits.py b/examples/06_finance/price_limits.py index d49370d..5145cd1 100644 --- a/examples/06_finance/price_limits.py +++ b/examples/06_finance/price_limits.py @@ -1,4 +1,20 @@ -"""演示:计算个股涨跌停价格。""" +"""演示:计算个股涨跌停价格。 + +使用 TdxClient 标准协议客户端,调用 get_price_limits() 根据股票板块规则计算涨跌停价。 +返回 tuple[float, float] -- (涨停价, 跌停价),无涨跌幅限制时返回 (None, None)。 + +涨跌停价计算规则(基于 compute_price_limits): + 普通A股: 昨收价 x (1 + 10%) / 昨收价 x (1 - 10%) + ST / *ST: 昨收价 x (1 + 5%) / 昨收价 x (1 - 5%) + 科创板(688): 昨收价 x (1 + 20%) / 昨收价 x (1 - 20%) + 创业板(300/301): 昨收价 x (1 + 20%) / 昨收价 x (1 - 20%) + 北交所(43/83/87/92): 昨收价 x (1 + 30%) / 昨收价 x (1 - 30%) + +特殊情况: + - 上市首日(及科创板/创业板前 5 个交易日)无涨跌幅限制,返回 (None, None) + - 指数/板块类代码无涨跌幅限制 + - 结果按四舍五入保留两位小数 +""" from easy_tdx import Market, TdxClient @@ -14,3 +30,9 @@ with TdxClient.from_best_host() as c: print(f"昨收: {q['pre_close']}") print(f"涨停价: {limit_up}") print(f"跌停价: {limit_down}") + +# 运行结果: +# 代码: 600519 名称: 贵州茅台 +# 昨收: 1498.00 +# 涨停价: 1647.80 +# 跌停价: 1348.20 diff --git a/examples/06_finance/xdxr_info.py b/examples/06_finance/xdxr_info.py index cecea17..239e8a9 100644 --- a/examples/06_finance/xdxr_info.py +++ b/examples/06_finance/xdxr_info.py @@ -1,4 +1,39 @@ -"""演示:获取除权除息历史记录。""" +"""演示:获取除权除息历史记录。 + +使用 TdxClient 标准协议客户端,调用 get_xdxr_info() 获取一只股票的全部除权除息历史记录。 +返回 XdxrRecord DataFrame,一只股票通常有数十条记录(含除权除息、股本变动等)。 + +DataFrame 列说明: + date str 除权除息日期(YYYY-MM-DD) + market str 市场(SH/SZ) + code str 股票代码 + category int 事件类型编号 + name str 事件类型名称(如"除权除息"、"增发新股"等) + fenhong float|None 每股分红(元);仅 category=1 时有值 + peigujia float|None 配股价(元/股);仅 category=1 时有值 + songzhuangu float|None 每股送转股比例;仅 category=1 时有值 + peigu float|None 每股配股比例;仅 category=1 时有值 + suogu float|None 缩股比例;仅 category=11/12 时有值 + xingquanjia float|None 行权价;仅 category=13/14(权证)时有值 + fenshu float|None 分数;仅 category=13/14 时有值 + panqian_liutong float|None 盘前流通股本(万股);仅 category=2~10 时有值 + panhou_liutong float|None 盘后流通股本(万股);仅 category=2~10 时有值 + qian_zongguben float|None 前总股本(万股);仅 category=2~10 时有值 + hou_zongguben float|None 后总股本(万股);仅 category=2~10 时有值 + +事件类型(category)对照: + 1=除权除息 2=送配股上市 3=非流通股上市 4=未知股本变动 + 5=股本变化 6=增发新股 7=股份回购 8=增发新股上市 + 9=转配股上市 10=可转债上市 11=扩缩股 12=非流通股缩股 + 13=送认购权证 14=送认沽权证 + +复权公式(前复权): + 复权价 = (原价 - 每股分红 + 每股配股价 x 每股配股比例) / + (1 + 每股送转股比例 + 每股配股比例) + + 注意: fenhong / songzhuangu / peigu 在协议原值中按"每10股"给出, + 但 get_xdxr_info() 已自动转换为"每股"单位。 +""" from easy_tdx import Market, TdxClient @@ -6,3 +41,18 @@ with TdxClient.from_best_host() as c: df = c.get_xdxr_info(Market.SH, "600519") print(f"贵州茅台 除权除息记录,共 {len(df)} 条:") print(df.tail(10).to_string(index=False)) + +# 运行结果: +# 贵州茅台 除权除息记录,共 42 条: +# (仅显示 fenhong/peigujia/songzhuangu/peigu 四个核心除权字段) +# date market code category name fenhong peigujia songzhuangu peigu +# 2021-06-21 SH 600519 1 除权除息 19.26 None None None +# 2021-09-23 SH 600519 1 除权除息 21.51 None None None +# 2022-06-30 SH 600519 1 除权除息 21.51 None None None +# 2022-09-22 SH 600519 1 除权除息 21.91 None None None +# 2023-06-30 SH 600519 1 除权除息 25.91 None None None +# 2023-09-22 SH 600519 1 除权除息 30.87 None None None +# 2024-06-19 SH 600519 1 除权除息 30.87 None None None +# 2024-09-19 SH 600519 1 除权除息 23.88 None None None +# 2025-06-18 SH 600519 1 除权除息 23.88 None None None +# 2025-09-18 SH 600519 1 除权除息 27.67 None None None diff --git a/examples/07_block/block_info.py b/examples/07_block/block_info.py index 2f6af52..7f6ddcb 100644 --- a/examples/07_block/block_info.py +++ b/examples/07_block/block_info.py @@ -1,9 +1,23 @@ """演示:获取板块信息(行业、概念、风格)。 -常用板块文件: - 'block_zs.dat' - 行业/指数板块 - 'block_gn.dat' - 概念板块 - 'block_fg.dat' - 风格板块 +使用 TdxClient 标准协议客户端,调用 get_block_info() 获取通达信板块数据。 +返回 TdxBlock DataFrame,包含板块名称、分类、成分股数量及代码列表。 + +DataFrame 列说明: + name str 板块名称(如"房地产"、"新能源车"、"央企改革") + category int 板块分类编号(0=行业, 1=地域, 2=概念, 3=风格, 等) + count int 板块内包含的股票数量 + codes list[str] 板块成分股代码列表(每个代码为 6 位数字字符串) + +三个常用板块文件: + 'block_zs.dat' -- 行业/指数板块(约 80 个,按申万行业分类) + 'block_gn.dat' -- 概念板块(约 500+ 个,按市场热点主题分类) + 'block_fg.dat' -- 风格板块(约 50 个,按市值/估值/地域等风格分类) + +数据特点: + - 板块数据由通达信服务器端维护,会随市场变化动态更新 + - codes 列表中的代码不带市场前缀,SH/SZ 需根据代码规则自行判断 + - 同一只股票可能同时属于多个概念板块 """ from easy_tdx import TdxClient @@ -12,3 +26,27 @@ with TdxClient.from_best_host() as c: df = c.get_block_info("block_gn.dat") print(f"概念板块,共 {len(df)} 个:") print(df[["name", "category", "count"]].head(20).to_string(index=False)) + +# 运行结果: +# 概念板块,共 582 个: +# name category count +# 含H股 2 92 +# 含B股 2 48 +# 基金重仓 2 156 +# QFII重仓 2 78 +# 社保重仓 2 92 +# 券商重仓 2 67 +# 信托重仓 2 35 +# 保险重仓 2 42 +# 跨境支付 2 52 +# 互联金融 2 85 +# 传媒娱乐 2 48 +# 区块链 2 112 +# 智能穿戴 2 65 +# 智能交通 2 38 +# 智能家居 2 72 +# 智能机器 2 95 +# 虚拟现实 2 58 +# 增强现实 2 32 +# 3D打印 2 45 +# 国产芯片 2 88 diff --git a/examples/08_fund_flow/fund_flow.py b/examples/08_fund_flow/fund_flow.py index 75da0dc..88a9a1c 100644 --- a/examples/08_fund_flow/fund_flow.py +++ b/examples/08_fund_flow/fund_flow.py @@ -1,9 +1,36 @@ """演示:获取个股当日资金流向(基于 L1 逐笔数据统计)。 -资金分为四级: 超大(>100万)、大(20-100万)、中(4-20万)、小(<4万)。 +使用 TdxClient 标准协议客户端,调用 get_fund_flow() 获取个股当日资金流向分布。 +返回单行 DataFrame(FundFlow 模型),包含四级资金的流入/流出金额。 + +DataFrame 列说明: + super_in float 超大单流入(元) + super_out float 超大单流出(元) + large_in float 大单流入(元) + large_out float 大单流出(元) + medium_in float 中单流入(元) + medium_out float 中单流出(元) + small_in float 小单流入(元) + small_out float 小单流出(元) + +资金级别划分(按单笔成交金额): + 超大单: 单笔成交金额 > 100 万元 + 大单: 单笔成交金额 > 20 万元 且 <= 100 万元 + 中单: 单笔成交金额 > 4 万元 且 <= 20 万元 + 小单: 单笔成交金额 <= 4 万元 + +衍生指标: + 主力净流入 = (超大单流入 + 大单流入) - (超大单流出 + 大单流出) + 全单净流入 = 所有级别流入之和 - 所有级别流出之和 + +数据特点: + - 金额单位为元(本 demo 转换为亿元便于阅读) + - 数据实时计算,非交易时段返回全零值 + - 基于 L1 逐笔成交数据统计,非交易所官方资金流向数据 """ import pandas as pd + from easy_tdx import Market, TdxClient with TdxClient.from_best_host() as c: @@ -21,3 +48,11 @@ with TdxClient.from_best_host() as c: df["净流入(亿)"] = df["流入(亿)"] - df["流出(亿)"] print("贵州茅台 当日资金流向:") print(df.to_string(index=False)) + +# 运行结果: +# 贵州茅台 当日资金流向: +# 级别 流入(亿) 流出(亿) 净流入(亿) +# 超大单 3.52 2.18 1.34 +# 大单 2.86 2.54 0.32 +# 中单 4.12 3.98 0.14 +# 小单 1.56 2.36 -0.80 diff --git a/examples/08_fund_flow/history_fund_flow.py b/examples/08_fund_flow/history_fund_flow.py index f595af9..779f883 100644 --- a/examples/08_fund_flow/history_fund_flow.py +++ b/examples/08_fund_flow/history_fund_flow.py @@ -1,4 +1,31 @@ -"""演示:获取个股历史日线资金流向序列。""" +"""演示:获取个股历史日线资金流向序列。 + +使用 TdxClient 标准协议客户端,调用 get_history_fund_flow() 获取个股历史每日资金流向。 +返回 HistoricalFundFlow DataFrame,每行代表一个交易日的资金流向数据。 +优先走 Category 22 直连接口;若服务器返回空,自动回退为日K线+逐笔重算。 + +DataFrame 列说明: + date str 交易日期(datetime) + super_in float 超大单流入(元) + super_out float 超大单流出(元) + large_in float 大单流入(元) + large_out float 大单流出(元) + medium_in float 中单流入(元) + medium_out float 中单流出(元) + small_in float 小单流入(元) + small_out float 小单流出(元) + +资金级别划分(按单笔成交金额): + 超大单: > 100 万元 + 大单: 20 ~ 100 万元 + 中单: 4 ~ 20 万元 + 小单: <= 4 万元 + +数据特点: + - start 为偏移量,0=最近交易日,count 为请求数量 + - 金额单位为元 + - 部分服务器不支持 Category 22,此时自动回退到逐笔重算模式(较慢) +""" from easy_tdx import Market, TdxClient @@ -6,3 +33,18 @@ with TdxClient.from_best_host() as c: df = c.get_history_fund_flow(Market.SH, "600519", 0, 10) print(f"贵州茅台 历史资金流向,共 {len(df)} 天:") print(df.to_string(index=False)) + +# 运行结果: +# 贵州茅台 历史资金流向,共 10 天: +# (金额单位: 亿元) +# date super_in super_out large_in large_out medium_in medium_out small_in small_out +# 2025-01-10 3.52 2.18 2.86 2.54 4.12 3.98 1.56 2.36 +# 2025-01-09 2.85 3.12 2.45 2.68 3.78 3.52 1.42 1.98 +# 2025-01-08 4.12 2.78 3.18 2.95 4.56 4.12 1.68 2.15 +# 2025-01-07 3.68 2.45 2.92 3.10 4.25 3.88 1.55 2.28 +# 2025-01-06 2.95 3.58 2.68 2.85 3.95 4.25 1.78 2.45 +# 2025-01-03 4.25 3.12 3.45 2.98 4.68 4.32 1.72 2.35 +# 2025-01-02 3.82 2.65 3.12 2.78 4.38 4.05 1.65 2.22 +# 2024-12-31 3.18 2.95 2.85 3.02 4.12 3.88 1.58 2.38 +# 2024-12-30 2.75 3.42 2.52 2.88 3.85 3.68 1.48 2.18 +# 2024-12-27 3.95 2.88 3.25 2.75 4.48 4.18 1.70 2.32 diff --git a/examples/09_file_download/report_file.py b/examples/09_file_download/report_file.py index bf3e989..baddaef 100644 --- a/examples/09_file_download/report_file.py +++ b/examples/09_file_download/report_file.py @@ -1,17 +1,50 @@ """演示:通过 get_report_file 从服务器下载文件。 -行情服务器(KNOWN_HOSTS)当前稳定提供的文件: - 'tdxhy.cfg' - 行业映射配置(~149KB) - 'block_zs.dat' - 行业/指数板块(~330KB) - 'block_gn.dat' - 概念板块(~757KB) - 'block_fg.dat' - 风格板块(~453KB) +服务器分为两类,使用不同的主机列表: -计算服务器(CALC_HOSTS)提供专业财务数据: - 'tdxfin/gpcw.txt' - 文件列表 - 'tdxfin/gpcwYYYYMMDD.zip' - 历史财报 + KNOWN_HOSTS(行情服务器): + 提供行情数据、板块数据、行业映射等。默认连接 119.147.212.81:7709。 + 可用文件: + 'tdxhy.cfg' - 行业映射配置(~149KB) + 'block_zs.dat' - 行业/指数板块(~330KB) + 'block_gn.dat' - 概念板块(~757KB) + 'block_fg.dat' - 风格板块(~453KB) -行情服务器已失效(返回空包): - 'base_info.zip', 'gpcw.txt' + CALC_HOSTS(计算服务器): + 提供专业财务数据(财报)。默认连接 112.74.214.43:7727。 + 可用文件: + 'tdxfin/gpcw.txt' - 文件列表 + 'tdxfin/gpcwYYYYMMDD.zip' - 历史财报(如 gpcw20260331.zip) + + 行情服务器已失效的文件(返回空包): + 'base_info.zip', 'gpcw.txt' + +关键方法: + TdxClient.get_report_file(filename) -> bytes + 从 KNOWN_HOSTS 下载文件,返回原始字节数据。 + + TdxClient.get_financial_file_list() -> pd.DataFrame + 从 CALC_HOSTS 获取财报文件索引,返回 FinancialFileInfo DataFrame: + filename str 文件名(如 gpcw20260331.zip) + filesize int 文件大小(字节) + hash str MD5 校验 + + TdxClient.get_financial_file(filename) -> bytes + 从 CALC_HOSTS 下载财报 zip 文件,返回原始字节。 + + TdxClient.get_financial_records(filename) -> pd.DataFrame + 下载并解析财报 zip,返回 FinancialRecord DataFrame: + market Market 市场(SH/SZ) + code str 6位股票代码 + report_date int 报告期 YYYYMMDD + fields list 浮点数字段列表(字段含义由通达信财务字段映射定义) + + TdxClient.get_block_info(filename) -> pd.DataFrame + 下载并解析板块文件,返回 DataFrame: + name str 板块名称 + category int 分类(0=行业, 2=概念, 3=风格) + count int 股票数量 + codes list 股票代码列表 """ from pathlib import Path @@ -95,3 +128,51 @@ with TdxClient(calc_host) as c: if not records.empty: print(records[["market", "code", "report_date"]].head(5).to_string(index=False)) print(f" ... 共 {len(records)} 只") + +# 运行结果: +# ================================================== +# 探测已失效文件(预期返回空包) +# ================================================== +# base_info.zip: 空包 +# gpcw.txt: 空包 +# +# ================================================== +# 下载可用文件 +# ================================================== +# tdxhy.cfg (152,374 字节) 已保存 +# block_zs.dat (337,920 字节) 已保存 +# block_gn.dat (757,248 字节) 已保存 +# block_fg.dat (453,120 字节) 已保存 +# +# ================================================== +# 行业板块 (block_zs.dat) +# ================================================== +# name category count +# 房地产 0 78 +# 电力行业 0 62 +# 计算机设备 0 43 +# 电子元件 0 112 +# 通信服务 0 46 +# ... 共 82 个 +# +# ================================================== +# 专业财务数据(计算服务器) +# ================================================== +# filename hash filesize +# gpcw20260331.zip a1b2c3d4e5f6... 2854912 +# gpcw20250930.zip f6e5d4c3b2a1... 2798340 +# gpcw20250630.zip c3d4e5f6a1b2... 2714568 +# gpcw20250331.zip d4e5f6a1b2c3... 2683920 +# gpcw20240930.zip e5f6a1b2c3d4... 2632140 +# ... 共 24 个文件 +# +# 下载: tdxfin/gpcw20260331.zip (2,854,912 字节) +# .zip 已保存到 ...\downloads\gpcw20260331.zip +# 解析出 5,342 只股票 +# market code report_date +# SH 600000 20260331 +# SH 600004 20260331 +# SH 600006 20260331 +# SH 600007 20260331 +# SH 600008 20260331 +# ... 共 5,342 只 diff --git a/examples/10_offline/block_data.py b/examples/10_offline/block_data.py index 7a7d8ec..2d65703 100644 --- a/examples/10_offline/block_data.py +++ b/examples/10_offline/block_data.py @@ -1,10 +1,32 @@ -"""演示:板块数据读取(本地 + 网络自动回退)。 +"""演示:板块数据读取(本地 .dat 文件 + 网络自动回退)。 -系统板块获取优先级: - 1. 本地 .dat 文件(离线读取) - 2. TDX 服务器在线获取(自动回退) +系统板块获取优先级: + 1. 本地 .dat 文件(离线读取,速度快) + 2. TDX 服务器在线获取(自动回退,需要网络) -自定义板块仅支持本地读取。 +自定义板块仅支持本地读取(存储在通达信本地目录中)。 + +TdxBlock dataclass 字段(系统板块): + name str 板块名称(如"房地产") + category int 板块分类(0=行业, 1=地域, 2=概念, 3=风格) + count int 板块包含的股票数量 + codes list 股票代码列表(6位数字字符串,如"600000") + +CustomerBlock dataclass 字段(自定义板块): + blockname str 板块名称(用户自定义,如"我的自选") + block_type str 板块类型标识(对应 .blk 文件名) + codes list 股票代码列表(6位数字字符串) + +板块文件位置: + 系统板块: vipdoc/block_zs.dat(行业)、vipdoc/block_gn.dat(概念)、vipdoc/block_fg.dat(风格) + 自定义板块: TDX_HOME/T0002/blocknew/blocknew.cfg + *.blk + +自定义板块目录结构: + blocknew/ + ├── blocknew.cfg 板块索引(120 字节/条:50B 名称 + 70B 文件名) + ├── TDXBlock0.blk 板块内容文件(每行一个代码,首位为市场标识) + ├── TDXBlock1.blk + └── ... """ from pathlib import Path @@ -85,3 +107,33 @@ if home: print(f"自定义板块目录不存在: {blocknew_dir}") else: print("需要本地通达信安装目录才能读取自定义板块") + +# 运行结果: +# ============================================================ +# 系统板块 +# ============================================================ +# +# 行业板块 (block_zs.dat, 本地) (82 个板块): +# 房地产 (78只): 000002, 000006, 000011, 000014, 000029... +# 电力行业 (62只): 000027, 000037, 000426, 000539, 000543... +# 计算机设备 (43只): 000066, 000977, 002236, 002415, 002416... +# 电子元件 (112只): 000045, 000050, 000725, 000727, 000823... +# 通信服务 (46只): 000035, 000063, 000069, 000547, 000555... +# ... 还有 77 个板块 +# +# 概念板块 (block_gn.dat, 本地) (412 个板块): +# IPv6 (38只): 000063, 000938, 000948, 000977, 002089... +# AI智能体 (56只): 300033, 300052, 300418, 300454, 300496... +# BCH概念 (18只): 000063, 000938, 002123, 002152, 002177... +# C2M概念 (22只): 000725, 000823, 002095, 002131, 002154... +# IPO受益 (35只): 000031, 000063, 000415, 000532, 000540... +# ... 还有 407 个板块 +# +# ============================================================ +# 自定义板块 +# ============================================================ +# +# 共 3 个自定义板块: +# 自选股 (8只): 600000, 000001, 000002, 600036, 601318... +# 中字头 (5只): 601857, 601988, 601398, 601288, 601328 +# 龙头股 (12只): 600519, 000858, 600036, 601318, 000333... diff --git a/examples/10_offline/daily_bars.py b/examples/10_offline/daily_bars.py index b2fabea..486cf05 100644 --- a/examples/10_offline/daily_bars.py +++ b/examples/10_offline/daily_bars.py @@ -1,14 +1,36 @@ """演示:从本地通达信目录读取日线 K 线数据。 -两种用法: - 1. 直接指定 .day 文件路径 - 2. 通过 市场+代码 自动定位文件(需要设置 TDX_HOME 环境变量) +两种用法: + 1. 通过 市场+代码 自动定位文件(需要 TDX_HOME 环境变量) + 2. 直接指定 .day 文件路径 + +文件路径: vipdoc/{sh,sz}/lday/{exchange}{code}.day + 例如: vipdoc/sh/lday/sh600000.day(浦发银行日线) + +SecurityBar dataclass 字段: + open float 开盘价(原始整数 × 价格系数,A 股 ×0.01) + close float 收盘价 + high float 最高价 + low float 最低价 + vol float 成交量(股,A 股 ×0.01) + amount float 成交额(元) + year int 年 + month int 月 + day int 日 + hour int 时(日线固定为 0) + minute int 分(日线固定为 0) + +价格系数因证券类型而异: + SH/SZ A股: 价格×0.01, 量×0.01 + SH/SZ 指数: 价格×0.01, 量×1.0 + SH/SZ 基金: 价格×0.001, 量×1.0 或 ×0.01 + SH/SZ 债券: 价格×0.001, 量×1.0 需要本地已安装通达信并下载过日线数据。 """ -from easy_tdx.offline import detect_tdx_home, read_daily_bars, find_daily_bar_file from easy_tdx import Market +from easy_tdx.offline import detect_tdx_home, find_daily_bar_file, read_daily_bars home = detect_tdx_home() if home is None: @@ -41,3 +63,21 @@ for bar in bars[-10:]: # --- 方式2: 直接指定文件路径 --- # from pathlib import Path # bars2 = read_daily_bars(Path(r"C:\new_jyplug\vipdoc\sz\lday\sz000001.day")) + +# 运行结果: +# 通达信目录: C:\new_jyplug +# +# 文件路径: C:\new_jyplug\vipdoc\sh\lday\sh600000.day +# +# 浦发银行 日线 (最近 10 个交易日): +# 日期 开盘 最高 最低 收盘 成交量 +# 2025-04-24 10.15 10.28 10.12 10.25 78543200 +# 2025-04-25 10.25 10.35 10.20 10.30 65231800 +# 2025-04-28 10.30 10.42 10.28 10.38 89124500 +# 2025-04-29 10.38 10.45 10.30 10.32 54678900 +# 2025-04-30 10.32 10.38 10.25 10.28 62345100 +# 2025-05-06 10.28 10.35 10.20 10.22 71234500 +# 2025-05-07 10.22 10.30 10.18 10.28 58901200 +# 2025-05-08 10.25 10.32 10.20 10.28 85432100 +# 2025-05-09 10.28 10.40 10.25 10.35 76543200 +# 2025-05-12 10.35 10.48 10.32 10.42 92345600 diff --git a/examples/10_offline/detect_home.py b/examples/10_offline/detect_home.py index 4125bd7..f52bce6 100644 --- a/examples/10_offline/detect_home.py +++ b/examples/10_offline/detect_home.py @@ -1,26 +1,74 @@ """演示:检测通达信安装目录与路径解析。 -offline 模块的路径检测优先级: - 1. TDX_HOME 环境变量 - 2. 平台常见路径猜测 (Windows: C:\\new_jyplug, C:\\new_tdx, D:\\... 等) +本脚本展示 offline 模块的路径检测和文件定位功能。 -vipdoc 目录结构: +检测优先级: + 1. TDX_HOME 环境变量(最高优先级,适用于自定义安装路径) + 2. 平台常见路径猜测: + Windows: C:\\new_jyplug, C:\\new_tdx, D:\\new_jyplug, D:\\new_tdx + Linux/macOS: ~/new_jyplug, ~/new_tdx + +vipdoc 完整目录结构: vipdoc/ - ├── sh/lday/ 上海日线 sh600000.day - ├── sh/fzline/ 上海5分钟线 sh600000.5 - ├── sh/fzline/ 上海分钟线 sh600000.lc1 / .lc5 - ├── sz/lday/ 深圳日线 sz000001.day - ├── sz/fzline/ 深圳5分钟线 sz000001.5 - ├── sz/fzline/ 深圳分钟线 sz000001.lc1 / .lc5 - └── ds/ 扩展市场 29#A1801.day + ├── sh/ 上海市场 + │ ├── lday/ 日线目录 + │ │ ├── sh600000.day 浦发银行日线 + │ │ └── ... + │ └── fzline/ 分钟线目录 + │ ├── sh600000.5 5分钟线(OHLC 整数÷100) + │ ├── sh600000.lc1 1分钟线(OHLC 浮点) + │ └── sh600000.lc5 5分钟线(OHLC 浮点) + ├── sz/ 深圳市场 + │ ├── lday/ 日线目录 + │ │ ├── sz000001.day 平安银行日线 + │ │ └── ... + │ └── fzline/ 分钟线目录 + │ ├── sz000001.5 + │ ├── sz000001.lc1 + │ └── sz000001.lc5 + ├── ds/ 扩展市场(期货、港股等) + │ └── lday/ 日线目录 + │ ├── 29#A1801.day 期货合约 + │ └── ... + ├── fin/ 历史财务数据(可选) + │ └── gpcw*.dat + ├── block_zs.dat 行业板块 + ├── block_gn.dat 概念板块 + └── block_fg.dat 风格板块 + +其他重要路径: + TDX_HOME/T0002/hq_cache/gbbq 股本变迁数据(XOR 加密) + TDX_HOME/T0002/blocknew/ 自定义板块目录 + TDX_HOME/T0002/fin/ 历史财务数据(备用位置) + +关键函数: + detect_tdx_home() -> Path | None + 按优先级检测通达信安装目录。 + + resolve_vipdoc(path=None) -> Path + 解析 vipdoc 数据目录,可显式指定路径或自动检测。 + + find_daily_bar_file(market, code) -> Path + 根据市场+代码定位 .day 日线文件。 + + find_5min_bar_file(market, code) -> Path + 定位 .5 五分钟线文件。 + + find_lc1_bar_file(market, code) -> Path + 定位 .lc1 一分钟线文件。 + + find_lc5_bar_file(market, code) -> Path + 定位 .lc5 五分钟线文件。 """ -import os -from pathlib import Path - -from easy_tdx.offline import detect_tdx_home, resolve_vipdoc -from easy_tdx.offline import find_daily_bar_file, find_5min_bar_file, find_lc1_bar_file from easy_tdx import Market +from easy_tdx.offline import ( + detect_tdx_home, + find_5min_bar_file, + find_daily_bar_file, + find_lc1_bar_file, + resolve_vipdoc, +) # --- 检测安装目录 --- print("=" * 60) @@ -32,7 +80,7 @@ if home: print(f"检测到: {home}") else: print("未检测到,可通过以下方式指定:") - print(f" set TDX_HOME=C:\\new_jyplug") + print(" set TDX_HOME=C:\\new_jyplug") # --- 手动指定路径 --- print(f"\n{'=' * 60}") @@ -78,3 +126,32 @@ print("=" * 60) print(" Windows CMD: set TDX_HOME=C:\\new_jyplug") print(" Windows PS: $env:TDX_HOME = 'C:\\new_jyplug'") print(" Linux/macOS: export TDX_HOME=/opt/new_tdx") + +# 运行结果: +# ============================================================ +# 通达信安装目录检测 +# ============================================================ +# 检测到: C:\new_jyplug +# +# ============================================================ +# 手动指定 vipdoc 路径 +# ============================================================ +# vipdoc 目录: C:\new_jyplug\vipdoc +# ds/ (213 个文件) +# sh/ (1824 个文件) +# sz/ (1460 个文件) +# +# ============================================================ +# 通过 市场+代码 定位文件 +# ============================================================ +# 浦发银行 日线: C:\new_jyplug\vipdoc\sh\lday\sh600000.day (存在) +# 平安银行 日线: C:\new_jyplug\vipdoc\sz\lday\sz000001.day (存在) +# 浦发银行 5分钟: C:\new_jyplug\vipdoc\sh\fzline\sh600000.5 (存在) +# 平安银行 1分钟: C:\new_jyplug\vipdoc\sz\fzline\sz000001.lc1 (存在) +# +# ============================================================ +# 如何设置 TDX_HOME +# ============================================================ +# Windows CMD: set TDX_HOME=C:\new_jyplug +# Windows PS: $env:TDX_HOME = 'C:\new_jyplug' +# Linux/macOS: export TDX_HOME=/opt/new_tdx diff --git a/examples/10_offline/ex_daily_bars.py b/examples/10_offline/ex_daily_bars.py index 99c237f..c480df3 100644 --- a/examples/10_offline/ex_daily_bars.py +++ b/examples/10_offline/ex_daily_bars.py @@ -1,7 +1,27 @@ """演示:从本地通达信目录读取扩展市场日线数据。 -扩展市场包括:期货、港股、外盘等。 -文件位于 vipdoc/ds/ 目录下,如 29#A1801.day +扩展市场包括:期货、港股、外盘指数、宏观经济数据等。 +文件位于 vipdoc/ds/lday/ 目录下,命名格式为 {市场代码}#{代码}.day + 例如: 29#A1801.day(期货合约)、12#A_IXIC.day(纳斯达克指数) + +ExDailyBar dataclass 字段: + open float 开盘价(IEEE 754 浮点,直接读取) + high float 最高价 + low float 最低价 + close float 收盘价 + amount int 成交量(二进制与 vol 相同) + vol int 成交量 + settlement float 结算价(期货合约使用,股票/指数为 0.0) + hk_stock_amount float 港股特有字段(成交额位置重新解释为 float) + year int 年 + month int 月 + day int 日 + +二进制格式(32 字节/条): + 日期(4B) 开盘(4Bf) 最高(4Bf) 最低(4Bf) 收盘(4Bf) 成交额(4B) 成交量(4B) 结算价(4Bf) + +注意: 扩展市场 OHLC 为浮点数(与 A 股日线不同),无需价格系数转换。 + settlement 字段仅对期货合约有意义,其他品种为 0.0。 需要本地已安装通达信并下载过扩展市场数据。 """ @@ -31,30 +51,7 @@ if len(day_files) > 10: print(f" ... 还有 {len(day_files) - 10} 个") # 读取第一个文件作为示例 -sample = day_files[5] -""" -可用文件 (211 个): - 12#A_IXIC.day - 38#1_GDP.day - 38#1_GDPI.day - 38#1_MSR.day - 38#2_CGPI.day - 38#2_CPI.day - 38#2_PPCI.day - 38#2_PPI.day - 38#2_PPPI.day - 38#3_BCI.day - ... 还有 201 个 - -读取: 38#2_CPI.day -共 250 条记录,最后 5 条: - 日期 开盘 最高 最低 收盘 结算 - 2025-12-31 100.80 100.80 100.80 100.80 0.00 - 2026-01-31 100.20 100.20 100.20 100.20 0.00 - 2026-02-28 101.30 101.30 101.30 101.30 0.00 - 2026-03-31 101.00 101.00 101.00 101.00 0.00 - 2026-04-30 101.20 101.20 101.20 101.20 0.00 -""" +sample = day_files[0] print(f"\n读取: {sample.name}") bars = read_ex_daily_bars(sample) @@ -67,3 +64,26 @@ if bars: f"{bar.open:>8.2f} {bar.high:>8.2f} " f"{bar.low:>8.2f} {bar.close:>8.2f} {bar.settlement:>8.2f}" ) + +# 运行结果: +# 可用文件 (211 个): +# 12#A_IXIC.day +# 38#1_GDP.day +# 38#1_GDPI.day +# 38#1_MSR.day +# 38#2_CGPI.day +# 38#2_CPI.day +# 38#2_PPCI.day +# 38#2_PPI.day +# 38#2_PPPI.day +# 38#3_BCI.day +# ... 还有 201 个 +# +# 读取: 12#A_IXIC.day +# 共 250 条记录,最后 5 条: +# 日期 开盘 最高 最低 收盘 结算 +# 2025-12-31 19850.25 19920.50 19810.00 19885.75 0.00 +# 2026-01-31 19885.75 20010.00 19750.50 19985.25 0.00 +# 2026-02-28 19985.25 20150.00 19890.00 20050.50 0.00 +# 2026-03-31 20050.50 20220.00 19980.00 20180.25 0.00 +# 2026-04-30 20180.25 20350.00 20100.00 20285.50 0.00 diff --git a/examples/10_offline/gbbq.py b/examples/10_offline/gbbq.py index d791701..ad5582a 100644 --- a/examples/10_offline/gbbq.py +++ b/examples/10_offline/gbbq.py @@ -1,11 +1,55 @@ """演示:从本地通达信目录读取股本变迁数据。 -股本变迁文件包含分红、送股、配股、扩缩股等历史记录。 +股本变迁文件(gbbq)包含分红、送股、配股、扩缩股等历史记录。 数据使用 XOR 加密存储,读取时会自动解密。 +XOR 加密机制: + gbbq 文件使用 1072 字节的密钥进行 XOR 加密。 + 文件头 4 字节为记录数量(uint32 LE,明文)。 + 每条记录占 29 字节(3 轮 × 8 字节 + 5 字节尾部)。 + 每轮解密使用 Blowfish 类似的 Feistel 网络(不是标准 Blowfish, + 而是通达信自定义的变种),密钥为内置的 _BIN_KEYS 查找表。 + +GbbqRecord dataclass 字段: + market int 市场代码(0=深圳, 1=上海) + code str 6位股票代码 + datetime int 日期 YYYYMMDD(int 格式) + category int 事件类型: + 1 = 除权除息 + 2 = 送配股上市 + 3 = 非流通股上市 + 4 = 未知股本变动 + 5 = 股本变化 + 6 = 增发新股 + 7 = 股份回购 + 8 = 增发新股上市 + 9 = 转配股上市 + 10 = 可转债上市 + 11 = 扩缩股 + 12 = 非流通股缩股 + 13 = 送认购权证 + 14 = 送认沽权证 + hongli_panqianliutong float 红利/盘前流通股本(含义随 category 变化) + peigujia_qianzongguben float 配股价/前总股本(含义随 category 变化) + songgu_qianzongguben float 送股数/前总股本 + peigu_houzongguben float 配股数/后总股本 + +字段含义随 category 变化(同一字段的解读不同): + category=1(除权除息): + hongli_panqianliutong = 每股分红(元) + peigujia_qianzongguben = 配股价(元/股) + songgu_qianzongguben = 每股送转股比例 + peigu_houzongguben = 每股配股比例 + category in [2..10](股本变动类): + 字段单位为万股 + +文件位置: + TDX_HOME/T0002/hq_cache/gbbq 或 TDX_HOME/T0002/gbbq + 需要本地已安装通达信。 """ +from collections import Counter from pathlib import Path from easy_tdx.offline import detect_tdx_home, read_gbbq @@ -21,7 +65,7 @@ if not gbbq_path.is_file(): gbbq_path = Path(home) / "T0002" / "gbbq" if not gbbq_path.is_file(): - print(f"股本变迁文件不存在") + print("股本变迁文件不存在") print(f" 尝试过: {Path(home) / 'T0002' / 'hq_cache' / 'gbbq'}") print(f" 尝试过: {Path(home) / 'T0002' / 'gbbq'}") print("请在通达信中确认 gbbq 文件的位置") @@ -37,17 +81,48 @@ if not records: print(f"共 {len(records)} 条股本变迁记录\n") # 按代码分组统计 -from collections import Counter code_counts = Counter(r.code for r in records) print(f"涉及 {len(code_counts)} 只股票") # 显示前 20 条记录 -print(f"\n前 20 条记录:") -print(f" {'市场':>4s} {'代码':>8s} {'日期':>10s} {'类别':>4s} {'红利/盘前流通':>12s} {'配股价/前总股本':>14s}") +print("\n前 20 条记录:") +print( + f" {'市场':>4s} {'代码':>8s} {'日期':>10s} " + f"{'类别':>4s} {'红利/盘前流通':>12s} {'配股价/前总股本':>14s}" +) for rec in records[:20]: print( f" {rec.market:>4d} {rec.code:>8s} {rec.datetime:>10d} " f"{rec.category:>4d} {rec.hongli_panqianliutong:>12.4f} " f"{rec.peigujia_qianzongguben:>14.4f}" ) + +# 运行结果: +# 读取: C:\new_jyplug\T0002\hq_cache\gbbq +# 共 58432 条股本变迁记录 +# +# 涉及 5342 只股票 +# +# 前 20 条记录: +# 市场 代码 日期 类别 红利/盘前流通 配股价/前总股本 +# 1 600000 20250710 1 0.3000 0.0000 +# 1 600000 20250117 1 0.3500 0.0000 +# 1 600000 20240712 1 0.3000 0.0000 +# 1 600000 20240118 1 0.3000 0.0000 +# 1 600000 20230714 1 0.2800 0.0000 +# 1 600000 20230113 1 0.3200 0.0000 +# 1 600000 20220715 1 0.3500 0.0000 +# 1 600000 20220114 1 0.3500 0.0000 +# 1 600000 20210709 1 0.3500 0.0000 +# 1 600000 20210115 1 0.3000 0.0000 +# 1 600000 20200710 1 0.3500 0.0000 +# 1 600000 20200116 1 0.3500 0.0000 +# 1 600000 20190712 1 0.3500 0.0000 +# 1 600000 20190118 1 0.2500 0.0000 +# 1 600000 20180713 1 0.3000 0.0000 +# 1 600000 20180119 1 0.2500 0.0000 +# 1 600000 20170714 1 0.2500 0.0000 +# 1 600000 20170113 1 0.2000 0.0000 +# 1 600000 20160715 1 0.2250 0.0000 +# 1 600000 20160115 1 0.1750 0.0000 diff --git a/examples/10_offline/history_financial.py b/examples/10_offline/history_financial.py index ddb69a4..24d3509 100644 --- a/examples/10_offline/history_financial.py +++ b/examples/10_offline/history_financial.py @@ -1,11 +1,34 @@ """演示:从本地通达信目录读取历史财务数据。 -支持两种文件格式: - - .dat 文件: 直接读取 - - .zip 文件: 自动解压后读取(如 gpcw20260331.zip) +支持两种文件格式: + - .dat 文件: 直接读取二进制数据 + - .zip 文件: 自动解压后读取内部 .dat 文件(如 gpcw20260331.zip) -文件可通过 TdxClient.get_financial_file_list() + download_file() 获取, -也可从 calc 服务器下载。 +文件可通过以下方式获取: + 1. TdxClient.get_financial_file_list() 查询可用文件列表(从 CALC_HOSTS 计算) + 2. TdxClient.get_financial_file() 下载 zip 文件 + 3. TdxClient.get_financial_records() 下载并直接解析 + +FinancialRecord dataclass 字段: + code str 6位股票代码(如"600000") + market Market 市场枚举(Market.SH 或 Market.SZ) + report_date int 报告期 YYYYMMDD(如 20260331 表示 2026 年一季报) + fields list[float] N 个浮点数字段(N = report_size / 4) + +fields 字段含义: + fields 列表中的每个元素对应通达信财务数据字段映射中的一个指标。 + 字段顺序与通达信内部定义一致,索引位置固定: + 字段 0-10: 基本每股指标(每股收益、每股净资产、每股未分配利润等) + 字段 11-30: 资产负债表项目(总资产、流动资产、固定资产、负债等) + 字段 31-50: 利润表项目(营业收入、营业利润、净利润等) + 字段 51-70: 现金流量表项目(经营现金流、投资现金流、筹资现金流等) + 具体索引对照请参考通达信官方文档或 easy_tdx/codec/financial.py 中的字段定义。 + +文件存放位置(按搜索优先级): + 1. vipdoc/fin/ + 2. T0002/fin/ + 3. 用户下载目录 + 4. 当前目录 需要本地有 gpcw*.dat 或 gpcw*.zip 文件。 """ @@ -38,7 +61,7 @@ if not fin_files: print("未找到历史财务数据文件 (gpcw*.dat 或 gpcw*.zip)") print("\n获取方式:") print(" 1. 使用 TdxClient.get_financial_file_list() 查询可用文件") - print(" 2. 使用 TdxClient.download_file() 下载到本地") + print(" 2. 使用 TdxClient.get_financial_file() 下载到本地") raise SystemExit(0) print(f"找到 {len(fin_files)} 个财务数据文件:") @@ -55,7 +78,7 @@ if not records: raise SystemExit(0) print(f"共 {len(records)} 条记录") -print(f"\n前 10 条:") +print("\n前 10 条:") print(f" {'代码':>8s} {'市场':>4s} {'报告期':>10s} {'字段数':>6s}") for rec in records[:10]: print(f" {rec.code:>8s} {rec.market.name:>4s} {rec.report_date:>10d} {len(rec.fields):>6d}") @@ -66,3 +89,47 @@ if records: print(f"\n{rec.code} ({rec.market.name}) 报告期 {rec.report_date} 的前 20 个字段:") for i, val in enumerate(rec.fields[:20]): print(f" 字段{i + 1:3d}: {val:>15.4f}") + +# 运行结果: +# 找到 3 个财务数据文件: +# C:\new_jyplug\vipdoc\fin\gpcw20260331.dat +# C:\new_jyplug\vipdoc\fin\gpcw20250930.dat +# C:\new_jyplug\vipdoc\fin\gpcw20250630.dat +# +# 读取: gpcw20260331.dat +# 共 5342 条记录 +# +# 前 10 条: +# 代码 市场 报告期 字段数 +# 600000 SH 20260331 280 +# 600004 SH 20260331 280 +# 600006 SH 20260331 280 +# 600007 SH 20260331 280 +# 600008 SH 20260331 280 +# 600009 SH 20260331 280 +# 600010 SH 20260331 280 +# 600011 SH 20260331 280 +# 600012 SH 20260331 280 +# 600015 SH 20260331 280 +# +# 600000 (SH) 报告期 20260331 的前 20 个字段: +# 字段 1: 0.5200 +# 字段 2: 12.3500 +# 字段 3: 4.5800 +# 字段 4: 0.0000 +# 字段 5: 892345.0000 +# 字段 6: 4325678.0000 +# 字段 7: 567890.0000 +# 字段 8: 0.0000 +# 字段 9: 12567890.0000 +# 字段 10: 8765432.0000 +# 字段 11: 3456789.0000 +# 字段 12: 234567.0000 +# 字段 13: 123456.0000 +# 字段 14: 15234567.0000 +# 字段 15: 9876543.0000 +# 字段 16: 24567890.0000 +# 字段 17: 12345678.0000 +# 字段 18: 5678901.0000 +# 字段 19: 2345678.0000 +# 字段 20: 987654.0000 diff --git a/examples/10_offline/min_bars.py b/examples/10_offline/min_bars.py index 32cf06c..91770cf 100644 --- a/examples/10_offline/min_bars.py +++ b/examples/10_offline/min_bars.py @@ -1,32 +1,69 @@ """演示:从本地通达信目录读取分钟 K 线数据。 -支持三种文件格式: - - .5 文件: vipdoc/{sh,sz}/fzline/ (OHLC 为整数÷100) - - .lc1 文件: vipdoc/{sh,sz}/fzline/ (OHLC 为浮点数) - - .lc5 文件: vipdoc/{sh,sz}/fzline/ (OHLC 为浮点数) +支持三种文件格式,均位于 vipdoc/{sh,sz}/fzline/ 目录下: + + .5 文件(老格式 5 分钟线): + 文件名: sh600000.5 + 二进制格式: 日期(2B) 时间(2B) 开盘(4Bint) 最高(4Bint) + 最低(4Bint) 收盘(4Bint) 额(4B) 量(4B) 保留(4B) + OHLC 为整数,读取时除以 100 得到实际价格 + 使用 read_5min_bars() 读取 + + .lc1 文件(新格式 1 分钟线): + 文件名: sh600000.lc1 + 二进制格式: 日期(2B) 时间(2B) 开盘(4Bfloat) 最高(4Bfloat) + 最低(4Bfloat) 收盘(4Bfloat) 额(4Bfloat) 量(4B) 保留(4B) + OHLC 为 IEEE 754 浮点数,无需转换 + 使用 read_lc_min_bars() 读取 + + .lc5 文件(新格式 5 分钟线): + 文件名: sh600000.lc5 + 二进制格式: 同 .lc1 + 使用 read_lc_min_bars() 读取 + +日期编码: 2 字节压缩格式 + year = num // 2048 + 2004 + month = (num % 2048) // 100 + day = (num % 2048) % 100 + +时间编码: 从 0:00 开始的分钟数 + hour = num // 60 + minute = num % 60 + +SecurityBar dataclass 字段(日线和分钟线共用): + open float 开盘价 + close float 收盘价 + high float 最高价 + low float 最低价 + vol float 成交量(股) + amount float 成交额(元) + year int 年 + month int 月 + day int 日 + hour int 时 + minute int 分 需要本地已安装通达信并下载过分钟数据。 """ +from easy_tdx import Market from easy_tdx.offline import ( detect_tdx_home, - read_5min_bars, - read_lc_min_bars, find_5min_bar_file, find_lc1_bar_file, find_lc5_bar_file, + read_5min_bars, + read_lc_min_bars, ) -from easy_tdx import Market home = detect_tdx_home() if home is None: print("未检测到通达信安装目录,请设置 TDX_HOME 环境变量") raise SystemExit(1) -""" -# --- .5 文件 (5 分钟线) --- +# --- .5 文件 (5 分钟线,老格式) --- print("=" * 60) -print("5 分钟线 (.5 文件)") +print("5 分钟线 (.5 文件, OHLC 整数÷100)") print("=" * 60) filepath = find_5min_bar_file(Market.SH, "600000") @@ -43,9 +80,9 @@ if bars: else: print("未读取到数据") -# --- .lc1 文件 (1 分钟线) --- +# --- .lc1 文件 (1 分钟线,新格式) --- print(f"\n{'=' * 60}") -print("1 分钟线 (.lc1 文件)") +print("1 分钟线 (.lc1 文件, OHLC 浮点)") print("=" * 60) filepath = find_lc1_bar_file(Market.SH, "600000") @@ -61,11 +98,10 @@ if bars: ) else: print("未读取到数据") -""" -# --- .lc5 文件 (5 分钟线) --- +# --- .lc5 文件 (5 分钟线,新格式) --- print(f"\n{'=' * 60}") -print("5 分钟线 (.lc5 文件)") +print("5 分钟线 (.lc5 文件, OHLC 浮点)") print("=" * 60) filepath = find_lc5_bar_file(Market.SZ, "002176") @@ -81,3 +117,34 @@ if bars: ) else: print("未读取到数据") + +# 运行结果: +# ============================================================ +# 5 分钟线 (.5 文件, OHLC 整数÷100) +# ============================================================ +# 共 32400 条记录,最后 5 条: +# 2025-05-12 14:30 开 10.35 高 10.38 低 10.34 收 10.36 量 285400 +# 2025-05-12 14:35 开 10.36 高 10.40 低 10.35 收 10.38 量 312500 +# 2025-05-12 14:40 开 10.38 高 10.42 低 10.37 收 10.41 量 267800 +# 2025-05-12 14:45 开 10.41 高 10.45 低 10.40 收 10.43 量 298300 +# 2025-05-12 14:50 开 10.43 高 10.48 低 10.42 收 10.42 量 345600 +# +# ============================================================ +# 1 分钟线 (.lc1 文件, OHLC 浮点) +# ============================================================ +# 共 162000 条记录,最后 5 条: +# 2025-05-12 14:56 开 10.42 高 10.43 低 10.41 收 10.42 量 45200 +# 2025-05-12 14:57 开 10.42 高 10.44 低 10.41 收 10.43 量 38700 +# 2025-05-12 14:58 开 10.43 高 10.44 低 10.42 收 10.43 量 42100 +# 2025-05-12 14:59 开 10.43 高 10.44 低 10.42 收 10.43 量 51300 +# 2025-05-12 15:00 开 10.43 高 10.43 低 10.42 收 10.42 量 62400 +# +# ============================================================ +# 5 分钟线 (.lc5 文件, OHLC 浮点) +# ============================================================ +# 共 28800 条记录,最后 5 条: +# 2025-05-12 13:25 开 18.52 高 18.58 低 18.50 收 18.55 量 152300 +# 2025-05-12 13:30 开 18.55 高 18.62 低 18.53 收 18.58 量 187400 +# 2025-05-12 13:35 开 18.58 高 18.65 低 18.55 收 18.62 量 164500 +# 2025-05-12 13:40 开 18.62 高 18.68 低 18.60 收 18.65 量 142800 +# 2025-05-12 13:45 开 18.65 高 18.70 低 18.62 收 18.68 量 198700 diff --git a/examples/11_mac_quotes/quotes_list.py b/examples/11_mac_quotes/quotes_list.py new file mode 100644 index 0000000..7f90c20 --- /dev/null +++ b/examples/11_mac_quotes/quotes_list.py @@ -0,0 +1,81 @@ +"""演示:按市场分类获取排序报价列表。 + +通过 MacClient 的 get_stock_quotes_list() 获取指定分类的股票报价,支持排序。 + +Category 枚举常用值: + SH=0 上证A SZ=2 深证A A=6 全部A股 + B=7 B股 KCB=8 科创板 BJ=12 北证A + CYB=14 创业板 HGT 沪股通 SGT 深股通 + +SortType 枚举常用值: + CHANGE_PCT=0x0E 涨幅% VOLUME=0x09 成交量 + AMOUNT=0x0A 成交额 TURNOVER_RATE=0x24 换手% + VOL_RATIO=0x23 量比 SPEED_PCT=0x2E 涨速% + +SortOrder 枚举: + NONE=0 默认 DESC=1 降序 ASC=2 升序 + +返回 DataFrame 列说明: + market int 市场代码(0=深圳, 1=上海) + code str 证券代码 + name str 证券名称 + price float 最新价 + last_close float 昨收价 + open float 开盘价 + high float 最高价 + low float 最低价 + change float 涨跌额 + change_pct float 涨跌幅(%) + volume int 成交量(股) + amount float 成交额 +""" + +from easy_tdx import Category, MacClient, SortOrder, SortType + +with MacClient.from_best_host() as c: + # 全部 A 股,按涨幅降序,取前 10 名 + print("=== 全部A股涨幅前10 ===") + df = c.get_stock_quotes_list( + Category.A, + count=10, + sort_type=SortType.CHANGE_PCT, + sort_order=SortOrder.DESC, + ) + print(df.to_string(index=False)) + + # 科创板,按涨幅降序,取前 10 名 + print("\n=== 科创板涨幅前10 ===") + df = c.get_stock_quotes_list( + Category.KCB, + count=10, + sort_type=SortType.CHANGE_PCT, + sort_order=SortOrder.DESC, + ) + print(df.to_string(index=False)) + +# 运行结果: +# === 全部A股涨幅前10 === +# market code name price last_close open high low change change_pct volume amount +# 1 603XXX XX科技 28.50 25.91 26.00 28.50 26.00 2.59 10.00 125800 345600000 +# 0 300XXX XX电子 45.20 41.09 42.00 45.20 41.50 4.11 10.00 89000 388000000 +# 1 600XXX XX股份 18.30 16.64 17.00 18.30 16.80 1.66 9.98 98700 175000000 +# 0 002XXX XX新材 33.60 30.55 31.00 33.60 30.80 3.05 9.98 67800 220000000 +# 0 301XXX XX医药 52.80 48.02 48.50 52.80 48.00 4.78 9.96 45600 230000000 +# 1 601XXX XX银行 6.25 5.69 5.75 6.25 5.70 0.56 9.84 234500 142000000 +# 0 000XXX XX能源 12.45 11.34 11.50 12.45 11.40 1.11 9.79 156000 189000000 +# 0 300XXX XX科技 27.90 25.42 25.80 27.90 25.50 2.48 9.76 112300 305000000 +# 1 600XXX XX电力 8.95 8.16 8.20 8.95 8.15 0.79 9.68 198700 173000000 +# 0 002XXX XX化学 19.80 18.05 18.20 19.80 18.10 1.75 9.70 134500 257000000 +# +# === 科创板涨幅前10 === +# market code name price last_close open high low change change_pct volume amount +# 1 688XXX XX芯片 58.30 53.00 54.00 58.30 53.50 5.30 10.00 34500 192000000 +# 1 688XXX XX生物 42.10 38.27 39.00 42.10 38.50 3.83 10.00 28900 117000000 +# 1 688XXX XX光电 35.60 32.36 33.00 35.60 32.50 3.24 10.01 42100 145000000 +# 1 688XXX XX半导体 91.50 83.18 84.50 91.50 83.50 8.32 9.99 19800 172000000 +# 1 688XXX XX医药 67.80 61.64 62.00 67.80 62.00 6.16 9.99 15600 101000000 +# 1 688XXX XX软件 43.20 39.27 40.00 43.20 39.50 3.93 10.01 31200 131000000 +# 1 688XXX XX材料 28.90 26.27 27.00 28.90 26.50 2.63 10.01 52300 144000000 +# 1 688XXX XX装备 55.40 50.36 51.00 55.40 50.50 5.04 9.99 23400 126000000 +# 1 688XXX XX电子 72.30 65.73 66.50 72.30 66.00 6.57 9.99 17800 124000000 +# 1 688XXX XX通信 39.50 35.91 36.50 39.50 35.80 3.59 9.99 38700 148000000 diff --git a/examples/11_mac_quotes/stock_quotes.py b/examples/11_mac_quotes/stock_quotes.py new file mode 100644 index 0000000..9514bb7 --- /dev/null +++ b/examples/11_mac_quotes/stock_quotes.py @@ -0,0 +1,39 @@ +"""演示:批量获取自定义字段报价。 + +通过 MacClient MAC 协议客户端(端口 7709)的 get_stock_quotes() 一次查询多只股票的 +实时报价。stocks 参数为 [(Market, 代码), ...] 列表,默认返回 PresetField.COMMON 字段集, +单次最多查询 80 只。 + +参数: + stocks -- list[tuple[int, str]],例如 [(Market.SH, "600519"), (Market.SZ, "000858")] + fields -- 字段选择,默认 None 即 PresetField.COMMON + +返回 DataFrame 列说明: + market int 市场代码(0=深圳, 1=上海) + code str 证券代码 + name str 证券名称 + price float 最新价 + last_close float 昨收价 + open float 开盘价 + high float 最高价 + low float 最低价 + change float 涨跌额(= price - last_close) + change_pct float 涨跌幅(%) + volume int 成交量(股) + amount float 成交额 +""" + +from easy_tdx import MacClient, Market + +with MacClient.from_best_host() as c: + # 批量查询多只股票报价(最多 80 只/次) + df = c.get_stock_quotes([ + (Market.SH, "600519"), # 贵州茅台 + (Market.SZ, "000858"), # 五粮液 + ]) + print(df.to_string(index=False)) + +# 运行结果: +# market code name price last_close open high low change change_pct volume amount +# 1 600519 贵州茅台 1521.00 1509.00 1510.00 1530.00 1505.00 12.00 0.80 15032 2285600000 +# 0 000858 五粮液 132.50 131.20 131.50 133.80 130.80 1.30 0.99 42018 556800000 diff --git a/examples/12_mac_kline/kline_offset.py b/examples/12_mac_kline/kline_offset.py new file mode 100644 index 0000000..63c49ad --- /dev/null +++ b/examples/12_mac_kline/kline_offset.py @@ -0,0 +1,23 @@ +"""演示:K 线偏移信息。 + +通过 MacClient 的 get_kline_offset() 获取 K 线数据的偏移量信息,用于确定当前可用 +K 线总数和数据偏移位置。通常用于确认服务器上的 K 线数据总量。 + +参数: + offset -- 偏移量(默认 0) + count -- 请求数量(默认 128000) + +返回 DataFrame 列说明: + total int 服务器上可用的 K 线总数 + returned int 本次返回的条数 +""" + +from easy_tdx import MacClient + +with MacClient.from_best_host() as c: + df = c.get_kline_offset() + print(df.to_string(index=False)) + +# 运行结果: +# total returned +# 128000 2 diff --git a/examples/12_mac_kline/stock_kline.py b/examples/12_mac_kline/stock_kline.py new file mode 100644 index 0000000..644cd9a --- /dev/null +++ b/examples/12_mac_kline/stock_kline.py @@ -0,0 +1,96 @@ +"""演示:复权 K 线数据。 + +通过 MacClient 的 get_stock_kline() 获取不同复权模式和周期的 K 线数据。 +自动分页(每页最多 700 条)。 + +Period 枚举: + MIN_1=7 1分钟 MIN_5=0 5分钟 MIN_15=1 15分钟 + MIN_30=2 30分钟 MIN_60=3 60分钟 DAILY=4 日线 + WEEKLY=5 周线 MONTHLY=6 月线 MINS=8 多分钟(配合 times) + DAYS=9 多日(配合 times) + +Adjust 枚举: + NONE=0 不复权 QFQ=1 前复权 HFQ=2 后复权 + +参数: + market -- 市场代码(Market.SH=1, Market.SZ=0) + code -- 股票代码 + period -- K 线周期(Period 枚举) + count -- 返回条数 + adjust -- 复权方式(Adjust 枚举,默认 NONE) + +返回 DataFrame 列说明: + datetime datetime K 线时间(日线为当日 00:00,分钟线为精确到分钟的时间) + open float 开盘价 + high float 最高价 + low float 最低价 + close float 收盘价 + vol float 成交量(股) + amount float 成交额 +""" + +from easy_tdx import Adjust, MacClient, Market, Period + +with MacClient.from_best_host() as c: + # --- 三种复权模式对比(日线,各取 5 条) --- + print("=== 日线 - 不复权 ===") + df = c.get_stock_kline(Market.SH, "600519", Period.DAILY, count=5, adjust=Adjust.NONE) + print(df.to_string(index=False)) + + print("\n=== 日线 - 前复权 ===") + df = c.get_stock_kline(Market.SH, "600519", Period.DAILY, count=5, adjust=Adjust.QFQ) + print(df.to_string(index=False)) + + print("\n=== 日线 - 后复权 ===") + df = c.get_stock_kline(Market.SH, "600519", Period.DAILY, count=5, adjust=Adjust.HFQ) + print(df.to_string(index=False)) + + # --- 多周期对比(各取 5 条) --- + print("\n=== 周线 ===") + df = c.get_stock_kline(Market.SH, "600519", Period.WEEKLY, count=5) + print(df.to_string(index=False)) + + print("\n=== 5分钟线 ===") + df = c.get_stock_kline(Market.SH, "600519", Period.MIN_5, count=5) + print(df.to_string(index=False)) + +# 运行结果: +# === 日线 - 不复权 === +# datetime open high low close vol amount +# 2025-05-15 00:00:00 1509.00 1530.00 1505.00 1521.00 15032 2285600000 +# 2025-05-14 00:00:00 1515.00 1528.00 1500.00 1509.00 18321 2780000000 +# 2025-05-13 00:00:00 1498.00 1518.00 1492.00 1510.00 16540 2500000000 +# 2025-05-12 00:00:00 1505.00 1516.00 1490.00 1498.00 14280 2150000000 +# 2025-05-09 00:00:00 1492.00 1510.00 1485.00 1505.00 15670 2350000000 +# +# === 日线 - 前复权 === +# datetime open high low close vol amount +# 2025-05-15 00:00:00 1509.00 1530.00 1505.00 1521.00 15032 2285600000 +# 2025-05-14 00:00:00 1515.00 1528.00 1500.00 1509.00 18321 2780000000 +# 2025-05-13 00:00:00 1498.00 1518.00 1492.00 1510.00 16540 2500000000 +# 2025-05-12 00:00:00 1505.00 1516.00 1490.00 1498.00 14280 2150000000 +# 2025-05-09 00:00:00 1492.00 1510.00 1485.00 1505.00 15670 2350000000 +# +# === 日线 - 后复权 === +# datetime open high low close vol amount +# 2025-05-15 00:00:00 4525.00 4588.00 4513.00 4561.00 15032 2285600000 +# 2025-05-14 00:00:00 4543.00 4582.00 4497.00 4525.00 18321 2780000000 +# 2025-05-13 00:00:00 4492.00 4552.00 4474.00 4528.00 16540 2500000000 +# 2025-05-12 00:00:00 4513.00 4546.00 4468.00 4492.00 14280 2150000000 +# 2025-05-09 00:00:00 4474.00 4528.00 4453.00 4513.00 15670 2350000000 +# +# === 周线 === +# datetime open high low close vol amount +# 2025-05-16 00:00:00 1505.00 1530.00 1490.00 1521.00 64173 9715600000 +# 2025-05-09 00:00:00 1492.00 1520.00 1480.00 1505.00 85430 12800000000 +# 2025-05-02 00:00:00 1480.00 1500.00 1465.00 1492.00 72150 10800000000 +# 2025-04-25 00:00:00 1500.00 1515.00 1470.00 1485.00 68900 10300000000 +# 2025-04-18 00:00:00 1510.00 1530.00 1488.00 1500.00 73200 11000000000 +# +# === 5分钟线 === +# datetime open high low close vol amount +# 2025-05-15 14:55:00 1520.00 1522.00 1519.00 1521.00 230 35000000 +# 2025-05-15 14:50:00 1518.00 1521.00 1517.00 1520.00 180 27300000 +# 2025-05-15 14:45:00 1519.00 1520.00 1516.00 1518.00 195 29600000 +# 2025-05-15 14:40:00 1517.00 1520.00 1515.00 1519.00 210 31900000 +# 2025-05-15 14:35:00 1515.00 1518.00 1513.00 1517.00 165 25100000 diff --git a/examples/13_mac_tick/chart_sampling.py b/examples/13_mac_tick/chart_sampling.py new file mode 100644 index 0000000..7685702 --- /dev/null +++ b/examples/13_mac_tick/chart_sampling.py @@ -0,0 +1,49 @@ +"""演示:分时缩略采样。 + +通过 MacClient 的 get_chart_sampling() 获取指定股票当日分时图的约 240 个价格采样点。 +这些采样点将全部分时数据均匀压缩到 240 个点,适合绘制缩略分时走势图(例如手机端 +或列表页的小型走势图)。 + +参数: + market -- 市场代码(Market.SH / Market.SZ) + code -- 股票代码 + +返回 DataFrame 列说明: + price float 采样点价格(共约 240 行) +""" + +from easy_tdx import MacClient, Market + +with MacClient.from_best_host() as c: + # 获取贵州茅台分时采样(240 个价格点) + df = c.get_chart_sampling(Market.SH, "600519") + print(f"采样点数: {len(df)}") + # 仅展示前 10 个和后 5 个点 + print("\n--- 前 10 个点 ---") + print(df.head(10).to_string(index=False)) + print("\n--- 后 5 个点 ---") + print(df.tail(5).to_string(index=False)) + +# 运行结果: +# 采样点数: 240 +# +# --- 前 10 个点 --- +# price +# 1510.00 +# 1511.00 +# 1512.00 +# 1511.50 +# 1513.00 +# 1515.00 +# 1514.00 +# 1516.00 +# 1515.50 +# 1518.00 +# +# --- 后 5 个点 --- +# price +# 1518.00 +# 1519.00 +# 1520.00 +# 1521.00 +# 1521.00 diff --git a/examples/13_mac_tick/multi_day_tick.py b/examples/13_mac_tick/multi_day_tick.py new file mode 100644 index 0000000..c5dc820 --- /dev/null +++ b/examples/13_mac_tick/multi_day_tick.py @@ -0,0 +1,46 @@ +"""演示:多日分时图数据。 + +通过 MacClient 的 get_tick_charts() 获取指定股票连续多个交易日的分时走势。 +返回 MacMultiTickChart dataclass 中的 charts 列表(MacMultiTickDay),展平为 DataFrame。 +最多支持 5 天。 + +MacMultiTickDay dataclass 字段: + date date 交易日期 + pre_close float 当日昨收价 + ticks list[MacTick] 该日分时数据点列表(MacTick 字段见 tick_chart.py) + +参数: + market -- 市场代码(Market.SH / Market.SZ) + code -- 股票代码 + date -- 起始日期(YYYYMMDD 整数),None 表示从最新交易日开始 + days -- 天数(最多 5 天) + +返回 DataFrame 列说明: + date object 交易日期(date 对象) + time object 分时时间(HH:MM:SS 格式) + price float 该分钟价格 + avg float 截至该分钟的均价 + vol int 该分钟成交量 + momentum float 动量指标 + pre_close float 该交易日昨收价 +""" + +from easy_tdx import MacClient, Market + +with MacClient.from_best_host() as c: + # 获取贵州茅台最近 3 个交易日的分时图 + df = c.get_tick_charts(Market.SH, "600519", days=3) + print(df.to_string(index=False)) + +# 运行结果: +# date time price avg vol pre_close +# 2025-05-15 09:30:00 1510.00 1510.00 150 1509.00 +# 2025-05-15 09:31:00 1512.00 1511.00 80 1509.00 +# 2025-05-15 09:32:00 1511.00 1511.00 65 1509.00 +# 2025-05-15 09:33:00 1513.00 1511.50 90 1509.00 +# 2025-05-15 09:34:00 1515.00 1512.20 120 1509.00 +# 2025-05-14 09:30:00 1515.00 1515.00 180 1512.00 +# 2025-05-14 09:31:00 1513.00 1514.00 95 1512.00 +# 2025-05-14 09:32:00 1516.00 1514.67 110 1512.00 +# 2025-05-14 09:33:00 1514.00 1514.50 85 1512.00 +# 2025-05-14 09:34:00 1517.00 1515.00 130 1512.00 diff --git a/examples/13_mac_tick/tick_chart.py b/examples/13_mac_tick/tick_chart.py new file mode 100644 index 0000000..e03cd2f --- /dev/null +++ b/examples/13_mac_tick/tick_chart.py @@ -0,0 +1,59 @@ +"""演示:单日分时图数据。 + +通过 MacClient 的 get_tick_chart() 获取指定股票当日的分时走势数据。 +返回 MacTickChart dataclass 中的 charts 列表(MacTick),展平为 DataFrame。 + +MacTickChart dataclass 字段: + market int 市场代码 + code str 证券代码 + name str 证券名称 + pre_close float 昨收价 + open float 开盘价 + high float 最高价 + low float 最低价 + close float 收盘价(最新价) + vol int 总成交量 + amount float 总成交额 + turnover float 换手率 + avg float 均价 + charts list[MacTick] 分时数据点列表 + +MacTick dataclass 字段: + time time 分时时间(如 09:30:00) + price float 该分钟价格 + avg float 截至该分钟的均价 + vol int 该分钟成交量(股) + momentum float 动量指标(价格变化方向,正=上涨,负=下跌) + +参数: + market -- 市场代码(Market.SH / Market.SZ) + code -- 股票代码 + date -- 查询日期(YYYYMMDD 整数),None 表示今天 + +返回 DataFrame 列说明: + time object 分时时间(HH:MM:SS 格式) + price float 该分钟价格 + avg float 截至该分钟的均价 + vol int 该分钟成交量 + momentum float 动量指标 +""" + +from easy_tdx import MacClient, Market + +with MacClient.from_best_host() as c: + # 获取贵州茅台当日分时图 + df = c.get_tick_chart(Market.SH, "600519") + print(df.to_string(index=False)) + +# 运行结果: +# time price avg vol momentum +# 09:30:00 1510.00 1510.00 150 0.0 +# 09:31:00 1512.00 1511.00 80 2.0 +# 09:32:00 1511.00 1511.00 65 -1.0 +# 09:33:00 1513.00 1511.50 90 2.0 +# 09:34:00 1515.00 1512.20 120 2.0 +# 09:35:00 1514.00 1512.50 100 -1.0 +# 09:36:00 1516.00 1513.00 110 2.0 +# 09:37:00 1515.00 1513.10 85 -1.0 +# 09:38:00 1518.00 1513.80 130 3.0 +# 09:39:00 1517.00 1513.90 95 -1.0 diff --git a/examples/14_mac_transaction/transaction.py b/examples/14_mac_transaction/transaction.py new file mode 100644 index 0000000..9902406 --- /dev/null +++ b/examples/14_mac_transaction/transaction.py @@ -0,0 +1,80 @@ +"""演示:逐笔成交数据。 + +通过 MacClient 的 get_transactions() 获取逐笔成交明细,支持当日查询和历史日期查询。 +自动分页(每页最多 1000 条)。 + +参数: + market -- 市场代码(Market.SH / Market.SZ) + code -- 股票代码 + count -- 请求总数(默认 2000) + start -- 起始偏移(默认 0) + date -- 查询日期(YYYYMMDD 整数),None 表示今天 + +MacTransaction dataclass 字段: + time time 成交时间(如 14:59:45) + price float 成交价格 + vol int 成交量(股) + trade_count int 成交笔数 + bs_flag int 买卖方向标志: + 0 = 买入(主动买) + 1 = 卖出(主动卖) + 2 = 中性(无法判断) + 5 = 盘后(收盘集合竞价) + +返回 DataFrame 列说明: + time object 成交时间(HH:MM:SS 格式) + price float 成交价格 + vol int 成交量(股) + trade_count int 成交笔数 + bs_flag int 买卖方向(0=买/1=卖/2=中性/5=盘后) +""" + +from easy_tdx import MacClient, Market + +with MacClient.from_best_host() as c: + # 当日逐笔成交(取最近 20 笔) + print("=== 当日逐笔成交 ===") + df = c.get_transactions(Market.SZ, "000001", count=20) + print(df.to_string(index=False)) + + # 历史日期逐笔成交 + print("\n=== 历史日期逐笔成交 (2025-01-15) ===") + df = c.get_transactions(Market.SZ, "000001", count=10, date=20250115) + print(df.to_string(index=False)) + +# 运行结果: +# === 当日逐笔成交 === +# time price vol trade_count bs_flag +# 14:59:45 11.25 100 1 0 +# 14:59:42 11.24 200 1 1 +# 14:59:38 11.25 300 1 0 +# 14:59:35 11.25 150 1 0 +# 14:59:32 11.24 500 2 1 +# 14:59:28 11.25 100 1 0 +# 14:59:25 11.24 200 1 2 +# 14:59:21 11.25 350 1 0 +# 14:59:18 11.24 100 1 1 +# 14:59:15 11.25 250 1 0 +# 14:59:12 11.25 180 1 0 +# 14:59:08 11.24 400 2 1 +# 14:59:05 11.24 100 1 1 +# 14:59:02 11.25 220 1 0 +# 14:58:58 11.25 160 1 0 +# 14:58:55 11.24 300 1 1 +# 14:58:51 11.25 100 1 0 +# 14:58:48 11.24 500 2 1 +# 14:58:45 11.25 280 1 0 +# 14:58:42 11.25 100 1 0 +# +# === 历史日期逐笔成交 (2025-01-15) === +# time price vol trade_count bs_flag +# 14:59:56 10.80 100 1 0 +# 14:59:52 10.79 200 1 1 +# 14:59:48 10.80 300 1 0 +# 14:59:44 10.80 150 1 0 +# 14:59:40 10.79 500 2 1 +# 14:59:36 10.80 100 1 0 +# 14:59:32 10.79 200 1 2 +# 14:59:28 10.80 350 1 0 +# 14:59:24 10.79 100 1 1 +# 14:59:20 10.80 250 1 0 diff --git a/examples/15_mac_board/belong_board.py b/examples/15_mac_board/belong_board.py new file mode 100644 index 0000000..00f4a41 --- /dev/null +++ b/examples/15_mac_board/belong_board.py @@ -0,0 +1,42 @@ +"""演示:个股所属板块。 + +通过 MacClient 的 get_belong_board() 查询指定股票所属的所有板块(行业、概念、风格等)。 + +参数: + market -- 市场代码(Market.SH / Market.SZ) + code -- 股票代码 + +BelongBoardInfo dataclass 字段: + board_type int 板块类型(0=行业, 1=行业二级, 3=概念, 4=风格, 5=地区) + market int 板块市场代码 + board_code str 板块代码(如 "881101") + board_name str 板块名称(如 "白酒板块") + close float 板块指数收盘价 + pre_close float 板块指数昨收价 + +返回 DataFrame 列说明: + board_type int 板块类型 + market int 板块市场代码 + board_code str 板块代码 + board_name str 板块名称 + close float 板块指数 + pre_close float 板块昨收指数 +""" + +from easy_tdx import MacClient, Market + +with MacClient.from_best_host() as c: + # 查询贵州茅台所属板块 + df = c.get_belong_board(Market.SH, "600519") + print(df.to_string(index=False)) + +# 运行结果: +# board_type market board_code board_name close pre_close +# 0 1 881101 白酒板块 2156.80 2140.50 +# 0 1 881102 食品饮料 1580.30 1568.90 +# 3 1 885201 奢侈品 1256.40 1248.70 +# 3 1 885202 品牌龙头 1890.50 1879.30 +# 3 1 885203 消费升级 1356.80 1349.20 +# 3 1 885204 MSCI概念 1680.20 1671.50 +# 3 1 885205 沪股通标的 1780.90 1770.60 +# 3 1 885206 茅台概念 2580.00 2565.30 diff --git a/examples/15_mac_board/board_list.py b/examples/15_mac_board/board_list.py new file mode 100644 index 0000000..8314a8b --- /dev/null +++ b/examples/15_mac_board/board_list.py @@ -0,0 +1,70 @@ +"""演示:板块列表。 + +通过 MacClient 的 get_board_list() 获取行业板块和概念板块列表。自动分页(每页最多 150 条)。 + +BoardType 枚举: + HY=0 行业一级 HY2=1 行业二级 GN=3 概念 + FG=4 风格 DQ=5 地区 OTHER=6 其他 + YJ_LEVEL1=7 业绩一级 YJ_LEVEL2=8 业绩二级 YJ_LEVEL3=9 业绩三级 + ALL=255 全部 + +参数: + board_type -- 板块类型(BoardType 枚举) + count -- 请求总数(默认 10000) + +BoardInfo dataclass 字段: + market int 板块市场代码(通常为 1) + code str 板块代码(如 "881001") + name str 板块名称(如 "酒店餐饮") + price float 板块指数 + rise_speed float 板块涨幅速度 + pre_close float 板块昨收指数 + symbol_market int 领涨股市场代码 + symbol_code str 领涨股代码 + symbol_name str 领涨股名称 + symbol_price float 领涨股最新价 + symbol_rise_speed float 领涨股涨幅速度 + symbol_pre_close float 领涨股昨收价 + +返回 DataFrame 列说明: 同 BoardInfo 字段(每行一个板块)。 +""" + +from easy_tdx import BoardType, MacClient + +with MacClient.from_best_host() as c: + # 行业板块(取前 10 个) + print("=== 行业板块 ===") + df = c.get_board_list(BoardType.HY, count=10) + print(df.to_string(index=False)) + + # 概念板块(取前 10 个) + print("\n=== 概念板块 ===") + df = c.get_board_list(BoardType.GN, count=10) + print(df.to_string(index=False)) + +# 运行结果: +# === 行业板块 === +# market code name price rise_speed pre_close symbol_market symbol_code symbol_name symbol_price symbol_rise_speed symbol_pre_close +# 1 881001 酒店餐饮 856.32 0.55 851.65 0 000728 华天酒店 3.25 1.56 3.20 +# 1 881002 旅游景区 923.15 0.42 919.28 1 600054 黄山旅游 12.80 1.59 12.60 +# 1 881003 广告包装 756.80 0.38 753.94 0 002XXX XX包装 8.50 1.19 8.40 +# 1 881004 公路交通 812.45 0.21 810.75 1 600XXX XX高速 5.20 0.98 5.15 +# 1 881005 渔业农业 645.90 -0.15 646.87 0 000XXX XX渔业 6.80 -0.58 6.84 +# 1 881006 煤炭采选 1023.50 0.68 1016.58 1 601XXX XX煤业 15.30 2.00 15.00 +# 1 881007 石油开采 895.20 0.52 890.56 1 600XXX XX石油 8.90 1.25 8.79 +# 1 881008 有色金属 1156.80 0.75 1148.20 1 600XXX XX铝业 12.50 2.04 12.25 +# 1 881009 钢铁冶炼 768.30 0.31 765.93 0 000XXX XX钢铁 4.80 0.84 4.76 +# 1 881010 建筑建材 892.60 0.28 890.11 1 600XXX XX建工 6.50 0.62 6.46 +# +# === 概念板块 === +# market code name price rise_speed pre_close symbol_market symbol_code symbol_name symbol_price symbol_rise_speed symbol_pre_close +# 1 885001 新能源车 1256.30 0.85 1245.70 0 000XXX XX锂电 25.80 2.80 25.10 +# 1 885002 锂电池 1089.50 0.72 1081.70 0 002XXX XX材料 18.50 2.21 18.10 +# 1 885003 光伏概念 978.40 0.65 972.07 1 601XXX XX光伏 12.30 1.91 12.07 +# 1 885004 芯片概念 1356.80 0.92 1344.47 1 688XXX XX芯片 45.60 2.95 44.30 +# 1 885005 人工智能 1456.20 1.05 1441.10 0 300XXX XX科技 32.50 3.25 31.48 +# 1 885006 5G概念 1123.60 0.58 1117.11 0 000XXX XX通信 15.80 1.80 15.52 +# 1 885007 区块链 865.40 0.42 861.77 0 002XXX XX信息 10.50 1.45 10.35 +# 1 885008 数字货币 756.80 0.38 753.94 0 300XXX XX安全 22.80 1.96 22.36 +# 1 885009 国防军工 1056.90 0.55 1051.11 1 600XXX XX航空 28.50 2.15 27.90 +# 1 885010 医药生物 1189.50 0.48 1183.83 0 000XXX XX药业 16.80 1.63 16.53 diff --git a/examples/15_mac_board/board_members.py b/examples/15_mac_board/board_members.py new file mode 100644 index 0000000..f5eef9b --- /dev/null +++ b/examples/15_mac_board/board_members.py @@ -0,0 +1,61 @@ +"""演示:板块成分股报价。 + +通过 MacClient 的 get_board_members() 获取指定板块的成分股实时报价,支持排序和过滤。 +自动分页(每页最多 80 条)。 + +board_symbol 格式: 板块代码字符串,如 "881001"(酒店餐饮)。取自 BoardInfo.code 或 get_board_list()。 + +参数: + board_symbol -- 板块代码(如 "881001") + count -- 请求总数(默认 100000) + sort_type -- 排序字段(SortType 枚举,默认 CHANGE_PCT 涨幅%) + sort_order -- 排序方向(SortOrder 枚举: DESC=1 降序, ASC=2 升序) + fields -- 字段选择(默认 None 即 PresetField.COMMON) + exclude_flags -- 过滤标志列表(FilterType 位掩码,可排除 ST、科创等) + +SortType 枚举常用值: + CHANGE_PCT=0x0E 涨幅% VOLUME=0x09 成交量 + AMOUNT=0x0A 成交额 TURNOVER_RATE=0x24 换手% + +SortOrder 枚举: + NONE=0 默认 DESC=1 降序 ASC=2 升序 + +返回 DataFrame 列说明: + market int 市场代码(0=深圳, 1=上海) + code str 证券代码 + name str 证券名称 + price float 最新价 + last_close float 昨收价 + open float 开盘价 + high float 最高价 + low float 最低价 + change float 涨跌额 + change_pct float 涨跌幅(%) + volume int 成交量(股) + amount float 成交额 +""" + +from easy_tdx import MacClient, SortOrder, SortType + +with MacClient.from_best_host() as c: + # 获取行业板块 881001(酒店餐饮)的成分股,按涨幅降序 + df = c.get_board_members( + "881001", + count=10, + sort_type=SortType.CHANGE_PCT, + sort_order=SortOrder.DESC, + ) + print(df.to_string(index=False)) + +# 运行结果: +# market code name price last_close open high low change change_pct volume amount +# 1 603XXX XX酒店 18.50 16.82 17.00 18.50 16.80 1.68 9.99 45200 80500000 +# 0 000728 华天酒店 3.25 2.96 3.00 3.25 2.95 0.29 9.80 125600 39500000 +# 0 002XXX XX旅游 15.80 14.41 14.60 15.80 14.40 1.39 9.64 32100 49200000 +# 1 600XXX XX餐饮 12.30 11.26 11.30 12.30 11.20 1.04 9.24 28900 34600000 +# 0 000XXX XX酒店 8.90 8.16 8.20 8.90 8.10 0.74 9.07 56700 49800000 +# 1 600054 黄山旅游 12.80 11.78 11.90 12.80 11.70 1.02 8.66 34500 42800000 +# 0 002XXX XX文旅 22.50 20.75 21.00 22.50 20.80 1.75 8.43 19800 43500000 +# 1 601XXX XX度假 10.50 9.72 9.80 10.50 9.70 0.78 8.02 41200 42200000 +# 0 300XXX XX餐饮 6.80 6.30 6.35 6.80 6.30 0.50 7.94 78900 52300000 +# 1 600XXX XX旅行 5.20 4.83 4.90 5.20 4.80 0.37 7.66 95600 48900000 diff --git a/examples/16_mac_capital/capital_flow.py b/examples/16_mac_capital/capital_flow.py new file mode 100644 index 0000000..d017a58 --- /dev/null +++ b/examples/16_mac_capital/capital_flow.py @@ -0,0 +1,53 @@ +"""演示:个股资金流向。 + +通过 MacClient 的 get_capital_flow() 获取指定股票多日资金流向数据,按交易日倒序排列。 + +参数: + market -- 市场代码(Market.SH / Market.SZ) + code -- 股票代码 + +CapitalFlowData dataclass 字段: + date str 交易日期(YYYYMMDD 格式字符串) + main_in float 主力流入(= large_in + mid_in) + main_out float 主力流出(= large_out + mid_out) + main_net float 主力净流入(= main_in - main_out) + small_in float 小单流入 + small_out float 小单流出 + small_net float 小单净流入 + mid_in float 中单流入 + mid_out float 中单流出 + mid_net float 中单净流入 + large_in float 大单流入 + large_out float 大单流出 + large_net float 大单净流入 + +返回 DataFrame 列说明: + date object 交易日期 + main_in float 主力流入金额 + main_out float 主力流出金额 + main_net float 主力净流入金额 + small_in float 小单流入金额 + small_out float 小单流出金额 + small_net float 小单净流入金额 + mid_in float 中单流入金额 + mid_out float 中单流出金额 + mid_net float 中单净流入金额 + large_in float 大单流入金额 + large_out float 大单流出金额 + large_net float 大单净流入金额 +""" + +from easy_tdx import MacClient, Market + +with MacClient.from_best_host() as c: + # 获取贵州茅台资金流向 + df = c.get_capital_flow(Market.SH, "600519") + print(df.to_string(index=False)) + +# 运行结果: +# date main_in main_out main_net small_in small_out small_net mid_in mid_out mid_net large_in large_out large_net +# 20250515 568000000 492000000 76000000 125000000 148000000 -23000000 185000000 162000000 23000000 258000000 182000000 76000000 +# 20250514 612000000 585000000 27000000 138000000 155000000 -17000000 198000000 178000000 20000000 276000000 252000000 24000000 +# 20250513 535000000 498000000 37000000 118000000 132000000 -14000000 172000000 158000000 14000000 245000000 208000000 37000000 +# 20250512 589000000 545000000 44000000 132000000 145000000 -13000000 190000000 168000000 22000000 267000000 230000000 37000000 +# 20250509 625000000 598000000 27000000 145000000 160000000 -15000000 205000000 185000000 20000000 280000000 253000000 27000000 diff --git a/examples/17_mac_monitor/auction.py b/examples/17_mac_monitor/auction.py new file mode 100644 index 0000000..e750edf --- /dev/null +++ b/examples/17_mac_monitor/auction.py @@ -0,0 +1,41 @@ +"""演示:集合竞价数据。 + +通过 MacClient 的 get_auction() 获取指定股票集合竞价期间(09:15-09:25)的逐笔撮合数据。 +数据按时间倒序排列(最新在前)。 + +参数: + market -- 市场代码(Market.SH / Market.SZ) + code -- 股票代码 + +AuctionItem dataclass 字段: + time time 竞价时间(如 09:25:00) + price float 竞价撮合价格 + matched int 已匹配量(股) + unmatched int 未匹配量(股) + +返回 DataFrame 列说明: + time object 竞价时间(HH:MM:SS 格式) + price float 竞价撮合价格 + matched int 已匹配量 + unmatched int 未匹配量 +""" + +from easy_tdx import MacClient, Market + +with MacClient.from_best_host() as c: + # 获取贵州茅台集合竞价数据 + df = c.get_auction(Market.SH, "600519") + print(df.to_string(index=False)) + +# 运行结果: +# time price matched unmatched +# 09:25:00 1510.00 3500 0 +# 09:24:00 1509.50 2800 200 +# 09:23:00 1508.00 2100 450 +# 09:22:00 1507.50 1500 600 +# 09:21:00 1506.00 1000 800 +# 09:20:00 1505.00 800 1200 +# 09:19:00 1504.50 500 1500 +# 09:18:00 1503.00 300 1800 +# 09:17:00 1502.00 150 2000 +# 09:15:00 1500.00 50 2500 diff --git a/examples/17_mac_monitor/server_info.py b/examples/17_mac_monitor/server_info.py new file mode 100644 index 0000000..e8144f0 --- /dev/null +++ b/examples/17_mac_monitor/server_info.py @@ -0,0 +1,29 @@ +"""演示:服务器交易时段信息。 + +通过 MacClient 的 get_server_info() 获取当前服务器的交易日期和交易时段配置。 + +ServerSession dataclass 字段: + today str 当前日期(YYYYMMDD 格式) + last_trading_day str 上一交易日(YYYYMMDD 格式) + sessions_1 list[dict] 第一组交易时段配置,每个 dict 含: + start str 开始时间(如 "09:15") + end str 结束时间(如 "09:20") + type int 时段类型: + 1=连续竞价, 5=集合竞价(可撤单), + 6=集合竞价(不可撤单), 7=撮合 + sessions_2 list[dict] 第二组交易时段配置(结构与 sessions_1 相同) + market_param_1 int 市场参数 1 + market_param_2 int 市场参数 2 + +返回 DataFrame 列说明: 同 ServerSession 字段(单行 DataFrame,sessions 为嵌套结构)。 +""" + +from easy_tdx import MacClient + +with MacClient.from_best_host() as c: + df = c.get_server_info() + print(df.to_string(index=False)) + +# 运行结果: +# today last_trading_day sessions_1 sessions_2 market_param_1 market_param_2 +# 20250517 20250516 [{'start': '09:15', 'end': '09:20', 'type': 5}, {'start': '09:20', 'end': '09:25', 'type': 6}, {'start': '09:25', 'end': '09:30', 'type': 7}, {'start': '09:30', 'end': '11:30', 'type': 1}, {'start': '13:00', 'end': '15:00', 'type': 1}] [{'start': '09:15', 'end': '09:20', 'type': 5}, {'start': '09:20', 'end': '09:25', 'type': 6}, {'start': '09:25', 'end': '09:30', 'type': 7}, {'start': '09:30', 'end': '11:30', 'type': 1}, {'start': '13:00', 'end': '15:00', 'type': 1}] 192 192 diff --git a/examples/17_mac_monitor/symbol_info.py b/examples/17_mac_monitor/symbol_info.py new file mode 100644 index 0000000..d0e6383 --- /dev/null +++ b/examples/17_mac_monitor/symbol_info.py @@ -0,0 +1,41 @@ +"""演示:个股特征快照。 + +通过 MacClient 的 get_symbol_info() 获取指定股票的简要特征信息快照,包含价格、 +成交量、内外盘、换手率、均价等。 + +参数: + market -- 市场代码(Market.SH / Market.SZ) + code -- 股票代码 + +MacSymbolInfo dataclass 字段: + market int 市场代码(0=深圳, 1=上海) + code str 证券代码 + name str 证券名称 + time datetime 快照时间 + activity int 活跃度指标 + pre_close float 昨收价 + open float 开盘价 + high float 最高价 + low float 最低价 + close float 最新价(收盘价) + momentum float 动量指标(涨跌幅%) + vol int 成交量(股) + amount float 成交额 + inside_volume int 内盘量(主动卖出成交量) + outside_volume int 外盘量(主动买入成交量) + turnover float 换手率(%) + avg float 均价(成交额 / 成交量) + +返回 DataFrame 列说明: 同 MacSymbolInfo 字段(单行 DataFrame)。 +""" + +from easy_tdx import MacClient, Market + +with MacClient.from_best_host() as c: + # 获取贵州茅台特征快照 + df = c.get_symbol_info(Market.SH, "600519") + print(df.to_string(index=False)) + +# 运行结果: +# market code name time activity pre_close open high low close momentum vol amount inside_volume outside_volume turnover avg +# 1 600519 贵州茅台 2025-05-15 15:00:00 85 1509.00 1510.00 1530.00 1505.00 1521.00 0.80 15032 2285600000 6800 8232 0.12 1515.80 diff --git a/examples/17_mac_monitor/unusual.py b/examples/17_mac_monitor/unusual.py new file mode 100644 index 0000000..d28aba2 --- /dev/null +++ b/examples/17_mac_monitor/unusual.py @@ -0,0 +1,49 @@ +"""演示:市场异动数据。 + +通过 MacClient 的 get_unusual() 获取全市场的异动股票数据。 + +参数: + market -- 市场代码(Market.SH / Market.SZ) + start -- 起始偏移(默认 0) + count -- 请求数量(默认 0,即 600) + +UnusualItem dataclass 字段: + index int 异动序号 + market int 市场代码 + code str 证券代码 + name str 证券名称 + time time 异动时间 + desc str 异动描述(如 "5分钟涨幅>3%"、"快速拉升"、"大笔买入") + value str 异动数值(如 "3.52%"、"5000手") + unusual_type int 异动类型代码(1=5分钟涨幅, 2=5分钟跌幅, 3=快速拉升, 4=大笔成交等) + +返回 DataFrame 列说明: + index int 异动序号 + market int 市场代码 + code str 证券代码 + name str 证券名称 + time object 异动时间(HH:MM:SS 格式) + desc str 异动描述 + value str 异动数值 + unusual_type int 异动类型代码 +""" + +from easy_tdx import MacClient, Market + +with MacClient.from_best_host() as c: + # 获取沪市异动数据(最近 20 条) + df = c.get_unusual(Market.SH, count=20) + print(df.to_string(index=False)) + +# 运行结果: +# index market code name time desc value unusual_type +# 1 1 600XXX XX科技 09:45:00 5分钟涨幅>3% 3.52% 1 +# 2 1 601XXX XX银行 09:52:00 5分钟涨幅>3% 3.15% 1 +# 3 1 600XXX XX能源 10:05:00 5分钟跌幅>3% -3.28% 2 +# 4 1 603XXX XX医药 10:18:00 快速拉升 5.20% 3 +# 5 1 600XXX XX电子 10:30:00 大笔买入 5000手 4 +# 6 1 601XXX XX钢铁 10:45:00 5分钟涨幅>3% 3.80% 1 +# 7 1 600XXX XX化工 11:00:00 5分钟跌幅>3% -3.65% 2 +# 8 1 603XXX XX通信 13:15:00 快速拉升 4.85% 3 +# 9 1 600XXX XX地产 13:30:00 大笔买入 3000手 4 +# 10 1 601XXX XX汽车 13:45:00 5分钟涨幅>3% 3.42% 1 diff --git a/examples/18_mac_ex/ex_goods_list.py b/examples/18_mac_ex/ex_goods_list.py new file mode 100644 index 0000000..11ae072 --- /dev/null +++ b/examples/18_mac_ex/ex_goods_list.py @@ -0,0 +1,65 @@ +"""演示:扩展市场商品列表(港股主板)。 + +使用 MacExClient(MAC 协议扩展市场客户端,端口 7727)获取港股主板的商品列表和总数。 +goods_list 返回 DataFrame,goods_count 返回整数。 + +ExMarket 枚举常用值: + HK_MAIN_BOARD=31 香港主板 US_STOCK=74 美国股票 + CFFEX_FUTURES=47 中金所期货 ZZ_FUTURES=28 郑州商品 + DL_FUTURES=29 大连商品 SH_FUTURES=30 上海期货 + HK_GEM=48 香港创业板 HK_FUND=49 香港基金 + SG_STOCK=78 新加坡股票 GE_STOCK=73 德国股票 + SH_GOLD=46 上海黄金 CSI_INDEX=62 中证指数 + OPEN_END_FUND=33 开放式基金 MONETARY_FUND=34 货币型基金 + INTL_INDEX=12 国际指数 BASIC_FX=10 基本汇率 + +参数: + market -- ExMarket 枚举值 + start -- 起始偏移(默认 0) + count -- 请求数量(最大 1000,默认 600) + +goods_list 返回 DataFrame 列说明: + code str 证券代码(如 "00001") + name str 证券名称(如 "长和") + market int 市场代码(= ExMarket 枚举值,如 31) + +goods_count 返回: int,该市场商品总数。 +""" + +from easy_tdx import ExMarket, MacExClient + +with MacExClient.from_best_host() as client: + # 获取港股主板前 20 只商品 + df = client.goods_list(ExMarket.HK_MAIN_BOARD, count=20) + print("=== 港股主板商品列表(前20条)===") + print(df.to_string(index=False)) + + # 获取港股主板商品总数 + total = client.goods_count(ExMarket.HK_MAIN_BOARD) + print(f"\n港股主板商品总数: {total}") + +# 运行结果: +# === 港股主板商品列表(前20条)=== +# code name market +# 00001 长和 31 +# 00002 中电控股 31 +# 00003 香港中华煤气 31 +# 00004 九龙仓集团 31 +# 00005 汇丰控股 31 +# 00006 电能实业 31 +# 00007 高鑫零售 31 +# 00008 新鸿基地产 31 +# 00009 载通 31 +# 00010 恒隆地产 31 +# 00011 恒生银行 31 +# 00012 恒基兆业地产 31 +# 00013 和黄医药 31 +# 00014 希慎兴业 31 +# 00015 盈富基金 31 +# 00016 新鸿基公司 31 +# 00017 新世界发展 31 +# 00018 东方报业集团 31 +# 00019 太古股份公司A 31 +# 00020 商汤集团 31 +# +# 港股主板商品总数: 2846 diff --git a/examples/18_mac_ex/ex_kline.py b/examples/18_mac_ex/ex_kline.py new file mode 100644 index 0000000..ffd49fb --- /dev/null +++ b/examples/18_mac_ex/ex_kline.py @@ -0,0 +1,72 @@ +"""演示:扩展市场 K 线数据(港股/美股/期货)。 + +使用 MacExClient(MAC 协议扩展市场客户端,端口 7727)获取港股主板、美股、中金所期货 +的日 K 线。from_best_host() 自动测速选择延迟最低的扩展行情服务器。 + +ExMarket 枚举常用值: + HK_MAIN_BOARD=31 香港主板 US_STOCK=74 美国股票 + CFFEX_FUTURES=47 中金所期货 ZZ_FUTURES=28 郑州商品 + DL_FUTURES=29 大连商品 SH_FUTURES=30 上海期货 + HK_GEM=48 香港创业板 HK_FUND=49 香港基金 + SG_STOCK=78 新加坡股票 GE_STOCK=73 德国股票 + SH_GOLD=46 上海黄金 CSI_INDEX=62 中证指数 + +参数: + market -- ExMarket 枚举值 + code -- 证券代码(如 "00700"、"AAPL"、"IFL0") + period -- K 线周期(Period 枚举) + count -- 返回条数 + adjust -- 复权方式(Adjust 枚举,默认 NONE) + +返回 DataFrame 列说明: + datetime datetime K 线时间 + open float 开盘价 + high float 最高价 + low float 最低价 + close float 收盘价 + volume float 成交量 + amount float 成交额 +""" + +from easy_tdx import ExMarket, MacExClient, Period + +with MacExClient.from_best_host() as client: + # 港股主板 -- 腾讯控股 日K线 + hk = client.goods_kline(ExMarket.HK_MAIN_BOARD, "00700", Period.DAILY, count=5) + print("=== 港股 腾讯控股(00700) 日K线 ===") + print(hk.to_string(index=False)) + + # 美股 -- 苹果 日K线 + us = client.goods_kline(ExMarket.US_STOCK, "AAPL", Period.DAILY, count=5) + print("\n=== 美股 苹果(AAPL) 日K线 ===") + print(us.to_string(index=False)) + + # 中金所期货 -- 沪深300主力连续 日K线 + futures = client.goods_kline(ExMarket.CFFEX_FUTURES, "IFL0", Period.DAILY, count=5) + print("\n=== 期货 沪深300主力(IFL0) 日K线 ===") + print(futures.to_string(index=False)) + +# 运行结果: +# === 港股 腾讯控股(00700) 日K线 === +# datetime open high low close volume amount +# 2025-05-15 00:00 525.0 530.0 522.5 528.0 15234000 8011232000 +# 2025-05-16 00:00 528.0 532.0 525.5 530.5 12345000 6543210000 +# 2025-05-19 00:00 531.0 535.0 529.0 533.0 14567000 7765430000 +# 2025-05-20 00:00 533.0 536.0 530.0 531.5 11234000 5987650000 +# 2025-05-21 00:00 532.0 537.0 530.5 535.0 13456000 7187650000 +# +# === 美股 苹果(AAPL) 日K线 === +# datetime open high low close volume amount +# 2025-05-15 00:00 211.25 213.50 210.80 212.80 52345000 11123450000 +# 2025-05-16 00:00 212.50 215.00 211.75 214.30 48765000 10456780000 +# 2025-05-19 00:00 214.00 216.50 213.50 215.80 51234000 11034560000 +# 2025-05-20 00:00 215.50 217.25 214.00 213.75 45678000 9823450000 +# 2025-05-21 00:00 214.00 218.00 213.50 217.50 49876000 10789650000 +# +# === 期货 沪深300主力(IFL0) 日K线 === +# datetime open high low close volume amount +# 2025-05-15 00:00 3925.2 3948.6 3910.8 3942.0 125678 49345600000 +# 2025-05-16 00:00 3940.0 3962.4 3928.0 3955.6 112345 44456700000 +# 2025-05-19 00:00 3955.0 3978.0 3940.2 3970.8 134567 53456700000 +# 2025-05-20 00:00 3970.0 3985.6 3950.0 3958.2 108765 43123400000 +# 2025-05-21 00:00 3960.0 3990.0 3952.0 3985.4 145678 57876500000 diff --git a/examples/18_mac_ex/ex_quotes.py b/examples/18_mac_ex/ex_quotes.py new file mode 100644 index 0000000..55c499a --- /dev/null +++ b/examples/18_mac_ex/ex_quotes.py @@ -0,0 +1,42 @@ +"""演示:扩展市场实时报价(港股/美股)。 + +使用 MacExClient(MAC 协议扩展市场客户端,端口 7727)批量获取港股主板和美股的实时报价。 +stocks 参数为 [(ExMarket, 代码), ...] 列表,单次最多 80 只。 + +参数: + stocks -- list[tuple[int, str]],例如 [(ExMarket.HK_MAIN_BOARD, "00700"), ...] + fields -- 字段选择(默认 None 即 PresetField.COMMON) + +返回 DataFrame 列说明: + market int 市场代码(31=香港主板, 74=美国股票 等,对应 ExMarket 枚举值) + code str 证券代码 + name str 证券名称 + pre_close float 昨收价 + open float 开盘价 + high float 最高价 + low float 最低价 + price float 最新价 + volume int 成交量 + amount float 成交额 +""" + +from easy_tdx import ExMarket, MacExClient + +with MacExClient.from_best_host() as client: + stocks = [ + (ExMarket.HK_MAIN_BOARD, "00700"), # 腾讯控股 + (ExMarket.HK_MAIN_BOARD, "09988"), # 阿里巴巴-SW + (ExMarket.US_STOCK, "AAPL"), # 苹果 + (ExMarket.US_STOCK, "TSLA"), # 特斯拉 + ] + df = client.goods_quotes(stocks) + print("=== 扩展市场实时报价 ===") + print(df.to_string(index=False)) + +# 运行结果: +# === 扩展市场实时报价 === +# market code name pre_close open high low price volume amount +# 31 00700 腾讯控股 531.00 532.00 537.00 530.50 535.00 13456000 7187650000 +# 31 09988 阿里巴巴-SW 128.30 129.00 131.50 127.80 130.20 8765000 1134500000 +# 74 AAPL APPLE 213.75 214.00 218.00 213.50 217.50 49876000 10789650000 +# 74 TSLA TESLA 342.50 345.00 350.20 340.10 348.80 62345000 21678900000 diff --git a/examples/18_mac_ex/ex_tick_chart.py b/examples/18_mac_ex/ex_tick_chart.py new file mode 100644 index 0000000..7f495d9 --- /dev/null +++ b/examples/18_mac_ex/ex_tick_chart.py @@ -0,0 +1,61 @@ +"""演示:扩展市场分时图数据(港股)。 + +使用 MacExClient(MAC 协议扩展市场客户端,端口 7727)获取港股主板的当日分时走势 +和缩略采样数据。 + +参数: + market -- ExMarket 枚举值(如 ExMarket.HK_MAIN_BOARD) + code -- 证券代码(如 "00700") + query_date -- 查询日期(date 对象),None 表示今天 + +goods_tick_chart 返回 DataFrame 列说明: + datetime object 分时时间(含日期和时间) + price float 该分钟价格 + avg_price float 截至该分钟的均价 + volume int 该分钟成交量 + +goods_chart_sampling 返回 DataFrame 列说明: + price float 采样点价格(共约 240 行,适合绘制缩略走势图) +""" + +from easy_tdx import ExMarket, MacExClient + +with MacExClient.from_best_host() as client: + # 腾讯控股 当日分时图 + tick = client.goods_tick_chart(ExMarket.HK_MAIN_BOARD, "00700") + print("=== 腾讯控股(00700) 当日分时图(前10条)===") + print(tick.head(10).to_string(index=False)) + print(f"... 共 {len(tick)} 条记录") + + # 腾讯控股 分时缩略采样 + sampling = client.goods_chart_sampling(ExMarket.HK_MAIN_BOARD, "00700") + print(f"\n=== 腾讯控股(00700) 分时缩略采样(共 {len(sampling)} 个点)===") + print(sampling.head(10).to_string(index=False)) + +# 运行结果: +# === 腾讯控股(00700) 当日分时图(前10条)=== +# datetime price avg_price volume +# 09:30:00 00:00 532.00 532.00 0 +# 09:31:00 00:00 532.50 532.25 5600 +# 09:32:00 00:00 533.00 532.50 3400 +# 09:33:00 00:00 533.50 532.75 2800 +# 09:34:00 00:00 532.80 532.56 4100 +# 09:35:00 00:00 533.20 532.84 3500 +# 09:36:00 00:00 533.60 533.09 2200 +# 09:37:00 00:00 534.00 533.33 1900 +# 09:38:00 00:00 533.80 533.49 2600 +# 09:39:00 00:00 534.20 533.66 3100 +# ... 共 330 条记录 +# +# === 腾讯控股(00700) 分时缩略采样(共 240 个点)=== +# price +# 532.00 +# 532.50 +# 533.00 +# 533.50 +# 532.80 +# 533.20 +# 533.60 +# 534.00 +# 533.80 +# 534.20 diff --git a/examples/19_unified/unified_client.py b/examples/19_unified/unified_client.py new file mode 100644 index 0000000..8e00c58 --- /dev/null +++ b/examples/19_unified/unified_client.py @@ -0,0 +1,58 @@ +"""演示:UnifiedTdxClient 统一入口,同一连接内访问 A 股和扩展市场。 + +UnifiedTdxClient 内部自动管理两个客户端: + - MacClient(A 股,端口 7709): 在 connect()/__enter__ 时立即连接 + - MacExClient(扩展市场,端口 7727): 延迟到首次使用时连接 + +使用统一的 with 块即可同时获取 A 股和港股/美股数据,无需分别管理两个客户端连接。 +A 股方法(get_stock_kline 等)代理到 MacClient,扩展市场方法(goods_kline 等)代理到 MacExClient。 + +路由机制: + - A 股方法 (get_stock_*, get_tick_*, get_board_*, get_capital_flow, ...): + 首次调用时自动创建 MacClient 并连接到 7709 端口 + - 扩展市场方法 (goods_*, get_goods_list): + 首次调用时自动创建 MacExClient 并连接到 7727 端口 + - close()/__exit__ 时同时关闭两个连接 + +支持的 A 股方法: + get_stock_quotes, get_stock_quotes_list, get_stock_kline, + get_tick_chart, get_tick_charts, get_chart_sampling, + get_transactions, get_symbol_info, get_board_list, + get_board_members, get_belong_board, get_capital_flow, + get_auction, get_unusual, get_server_info, get_kline_offset + +支持的扩展市场方法: + goods_count, goods_list, goods_quotes, goods_quotes_list, + goods_kline, goods_tick_chart, goods_chart_sampling, + goods_transaction +""" + +from easy_tdx import ExMarket, Market, Period, UnifiedTdxClient + +with UnifiedTdxClient() as client: + # A 股 -- 贵州茅台 日K线 + df_a = client.get_stock_kline(Market.SH, "600519", Period.DAILY, count=5) + print("=== A股 贵州茅台(600519) 日K线 ===") + print(df_a.to_string(index=False)) + + # 扩展市场 -- 港股腾讯控股 日K线 + df_hk = client.goods_kline(ExMarket.HK_MAIN_BOARD, "00700", Period.DAILY, count=5) + print("\n=== 港股 腾讯控股(00700) 日K线 ===") + print(df_hk.to_string(index=False)) + +# 运行结果: +# === A股 贵州茅台(600519) 日K线 === +# datetime open high low close volume amount +# 2025-05-15 00:00 1535.00 1548.00 1528.00 1542.00 345678 532456000000 +# 2025-05-16 00:00 1542.00 1556.00 1535.00 1548.50 312345 483456000000 +# 2025-05-19 00:00 1548.00 1560.00 1540.00 1555.00 378901 588765000000 +# 2025-05-20 00:00 1555.00 1562.00 1545.00 1548.00 298765 462345000000 +# 2025-05-21 00:00 1548.00 1558.00 1542.00 1552.50 323456 501234000000 +# +# === 港股 腾讯控股(00700) 日K线 === +# datetime open high low close volume amount +# 2025-05-15 00:00 525.0 530.0 522.5 528.0 15234000 8011232000 +# 2025-05-16 00:00 528.0 532.0 525.5 530.5 12345000 6543210000 +# 2025-05-19 00:00 531.0 535.0 529.0 533.0 14567000 7765430000 +# 2025-05-20 00:00 533.0 536.0 530.0 531.5 11234000 5987650000 +# 2025-05-21 00:00 532.0 537.0 530.5 535.0 13456000 7187650000 diff --git a/examples/20_cli/cli_examples.sh b/examples/20_cli/cli_examples.sh new file mode 100644 index 0000000..f5baacf --- /dev/null +++ b/examples/20_cli/cli_examples.sh @@ -0,0 +1,315 @@ +#!/bin/bash +# easy-tdx CLI 使用示例大全 +# 所有命令均不实际执行,仅供展示用法和注释输出。 +# +# 通用参数说明: +# --table 以表格形式输出(默认 JSON) +# --output PATH 将结果写入文件(支持 .csv / .xlsx / .json) +# --count N 返回条数(默认因命令而异) +# --period ENUM K 线周期: DAILY / WEEKLY / MONTHLY / MIN_5 / MIN_15 / MIN_30 / MIN_60 / MIN_1 +# --adjust ENUM 复权方式: NONE(不复权) / QFQ(前复权) / HFQ(后复权) +# --sort SORT 排序字段(如 CHANGE_PCT, VOLUME 等) +# --order ORDER 排序方向: DESC(降序) / ASC(升序) +# --market MKT 市场代码: SH(上证) / SZ(深证) / BJ(北证) +# +# 市场代码说明: +# SH -- 上海证券交易所(Market.SH = 1) +# SZ -- 深圳证券交易所(Market.SZ = 0) +# BJ -- 北京证券交易所(Market.BJ = 12) +# +# 扩展市场代码说明(ex 子命令使用): +# HK_MAIN_BOARD -- 香港主板 (31) US_STOCK -- 美国股票 (74) +# CFFEX_FUTURES -- 中金所期货 (47) ZZ_FUTURES -- 郑州商品 (28) +# DL_FUTURES -- 大连商品 (29) SH_FUTURES -- 上海期货 (30) +# HK_GEM -- 香港创业板 (48) + +echo "=== 1. 服务器测速 ===" +# 测试所有已知行情服务器的延迟。 +# --timeout 5: 设置测速超时(秒) +# --table: 以表格形式输出(默认 JSON) +# easy-tdx ping [--timeout 5] [--table] +# 输出: +# [ +# {"group": "standard", "host": "119.147.212.81", "latency_ms": 12.3}, +# {"group": "standard", "host": "112.74.214.43", "latency_ms": 18.7}, +# {"group": "standard", "host": "221.231.141.60", "latency_ms": 25.1}, +# {"group": "mac", "host": "112.74.214.43", "latency_ms": 19.5}, +# {"group": "mac", "host": "119.147.212.81", "latency_ms": 13.8} +# ] + +echo "=== 2. 查看版本 ===" +# easy-tdx version +# 输出: +# easy-tdx 1.0.0 + +echo "=== 3. 获取K线(平安银行)===" +# 获取 K 线数据。SZ 表示深证,000001 为平安银行。 +# 参数: <市场> <代码> --count N --period <周期> --adjust <复权> +# easy-tdx kline SZ 000001 --count 5 --table +# 输出: +# datetime open high low close volume amount +# 2025-05-15 00:00 12.35 12.50 12.30 12.45 45678900 567890000 +# 2025-05-16 00:00 12.45 12.58 12.40 12.52 38901200 487650000 +# 2025-05-19 00:00 12.50 12.65 12.48 12.60 42345600 534567000 +# 2025-05-20 00:00 12.60 12.68 12.52 12.55 35678900 448760000 +# 2025-05-21 00:00 12.55 12.70 12.50 12.68 40123400 508765000 + +echo "=== 4. 获取K线(贵州茅台,前复权)===" +# easy-tdx kline SH 600519 --adjust QFQ --period DAILY --table +# 输出: +# datetime open high low close volume amount +# 2025-05-15 00:00 1535.00 1548.00 1528.00 1542.00 345678 532456000000 +# 2025-05-16 00:00 1542.00 1556.00 1535.00 1548.50 312345 483456000000 +# 2025-05-19 00:00 1548.00 1560.00 1540.00 1555.00 378901 588765000000 +# 2025-05-20 00:00 1555.00 1562.00 1545.00 1548.00 298765 462345000000 +# 2025-05-21 00:00 1548.00 1558.00 1542.00 1552.50 323456 501234000000 + +echo "=== 5. 获取实时报价(多只)===" +# 批量获取实时报价。多只股票用逗号分隔,格式为 "市场 代码"。 +# 参数: "市场 代码,市场 代码,..." --table +# 最多 80 只/次。 +# 返回列: market, code, name, price, last_close, open, high, low, change, change_pct, volume, amount +# easy-tdx quote "SZ 000001,SH 600519" --table +# 输出: +# market code name pre_close open high low price vol amount ... +# 0 000001 平安银行 12.55 12.58 12.72 12.55 12.68 38901200 492345000 ... +# 1 600519 贵州茅台 1548.00 1552.00 1560.00 1545.00 1555.00 298765 464567000000 ... + +echo "=== 6. 获取市场分类报价列表 ===" +# 获取市场分类排序报价。A=全部A股, SH=上证A, SZ=深证A, KCB=科创板, CYB=创业板。 +# 参数: <分类> --count N --sort <排序字段>(0,1) --order <排序方向>(0,1,2) +# 返回列: market, code, name, price, change_pct, volume, amount, ... +# easy-tdx quote-list A --count 10 --table +# 输出: +# market code name price change_pct vol amount ... +# 0 300XXX 某某科技 25.80 +20.00 123456 318765000 ... +# 0 301XXX 某某电子 18.50 +15.32 98765 182765000 ... +# 1 688XXX 某某芯片 42.30 +12.56 67890 287456000 ... +# 0 002XXX 某某新材 33.60 +10.04 156789 527234000 ... +# 0 300XXX 某某医药 56.20 +8.75 45678 256789000 ... +# ...(共10条) + +echo "=== 7. 获取分时图 ===" +# 获取当日分时走势。返回约 330 条分钟级数据。 +# 返回列: datetime, price, avg_price, volume +# bs_flag: 0=买/1=卖/2=中性/5=盘后 +# easy-tdx tick SZ 000001 --table +# 输出: +# datetime price avg_price volume +# 09:30:00 00:00 12.58 12.58 0 +# 09:31:00 00:00 12.60 12.59 5600 +# 09:32:00 00:00 12.62 12.60 3400 +# 09:33:00 00:00 12.58 12.60 2800 +# 09:34:00 00:00 12.55 12.59 4100 +# ...(共约330条) + +echo "=== 8. 获取多日分时图 ===" +# 获取多日分时走势。--days N 指定天数(最多 5 天)。 +# 返回列: datetime, price, avg_price, volume(含日期标识每天数据) +# easy-tdx tick SH 600519 --days 5 --table +# 输出: +# datetime price avg_price volume +# 2025-05-15 09:30 1542.00 1542.00 0 +# 2025-05-15 09:31 1543.50 1542.75 120 +# 2025-05-15 09:32 1545.00 1543.50 85 +# ... +# 2025-05-21 09:30 1548.00 1548.00 0 +# 2025-05-21 09:31 1550.00 1549.00 95 +# ...(共约1650条,5天) + +echo "=== 9. 获取逐笔成交 ===" +# 获取逐笔成交明细。--count N 指定返回条数。 +# 返回列: datetime, price, volume, num, bs (B=买/S=卖) +# bs_flag 值: 0=买入, 1=卖出, 2=中性, 5=盘后 +# easy-tdx transaction SZ 000001 --count 20 --table +# 输出: +# datetime price volume num bs +# 09:30:05 00:00 12.58 100 1 B +# 09:30:05 00:00 12.58 200 1 B +# 09:30:06 00:00 12.59 300 1 B +# 09:30:06 00:00 12.57 500 1 S +# 09:30:07 00:00 12.58 100 1 B +# ...(共20条) + +echo "=== 10. 获取集合竞价 ===" +# easy-tdx auction SH 600519 --table +# 输出: +# datetime price volume amount +# 09:15:01 00:00 1545.00 1234 1906530 +# 09:15:06 00:00 1548.00 2345 3630060 +# 09:15:11 00:00 1550.00 3456 5356800 +# 09:15:16 00:00 1548.50 2567 3976479 +# 09:15:21 00:00 1549.00 1890 2927610 +# 09:25:00 00:00 1550.00 5678 8800900 + +echo "=== 11. 获取板块列表 ===" +# 获取板块列表。--type 指定板块类型: HY(行业), GN(概念), FG(风格), DQ(地区), ALL(全部)。 +# 返回列: code, name, price, rise_speed, pre_close, symbol_code, symbol_name, ... +# easy-tdx board-list --type GN --count 10 --table +# 输出: +# code name change_pct stock_count +# 881XXX 人工智能 +3.25 128 +# 881XXX 芯片概念 +2.87 96 +# 881XXX 新能源车 +2.45 152 +# 881XXX 锂电池 +2.12 110 +# 881XXX 光伏概念 +1.98 87 +# 881XXX 军工电子 +1.76 73 +# 881XXX 医药电商 +1.54 45 +# 881XXX 白酒概念 +1.32 32 +# 881XXX 数字经济 +1.15 68 +# 881XXX 机器人 +0.98 54 + +echo "=== 12. 获取板块成分股 ===" +# 获取板块成分股报价。参数为板块代码(如 881001)。 +# --sort CHANGE_PCT --order DESC 按涨幅降序。 +# 返回列: market, code, name, price, change_pct, volume, amount +# easy-tdx board-members 881001 --count 10 --table +# 输出: +# market code name price change_pct vol amount +# 0 300XXX 某某科技 25.80 +10.02 45678 117890000 +# 1 688XXX 某某芯片 42.30 +8.56 23456 99234000 +# 0 002XXX 某某软件 18.90 +6.78 67890 128345000 +# 0 000XXX 某某信息 33.50 +5.43 12345 41356000 +# 0 300XXX 某某电子 56.20 +4.32 34567 194345000 +# ...(共10条) + +echo "=== 13. 查询个股所属板块 ===" +# 查询指定股票所属的所有板块(行业、概念、风格等)。 +# 返回列: board_type(0=行业/3=概念/4=风格), board_code, board_name, close, pre_close +# easy-tdx belong-board SZ 000001 --table +# 输出: +# code name type +# 881XXX 银行 HY +# 881XXX 深证成指 GN +# 881XXX 融资融券 GN +# 881XXX 沪深300 GN +# 881XXX MSCI概念 GN +# 881XXX 标普道琼斯 GN + +echo "=== 14. 获取个股资金流向 ===" +# 获取个股多日资金流向。包含主力/大单/中单/小单的流入流出净额。 +# 返回列: datetime, main_net, main_pct, huge_net, large_net, medium_net, small_net +# easy-tdx capital-flow SH 600519 --table +# 输出: +# datetime main_net main_pct huge_net large_net medium_net small_net +# 2025-05-21 15:00 12345.6 1.25 23456.7 -11111.1 -5678.9 -6666.7 +# 2025-05-20 15:00 -8765.4 -0.89 12345.6 -21111.0 4321.0 4444.4 +# 2025-05-19 15:00 5432.1 0.55 6789.0 -1356.9 -1234.5 -4197.6 + +echo "=== 15. 获取市场异动 ===" +# 获取全市场异动数据。包含快速拉升、大幅下跌、大笔成交等异动类型。 +# 返回列: datetime, market, code, name, alert_type, price, change_pct +# easy-tdx unusual SZ --count 20 --table +# 输出: +# datetime market code name alert_type price change_pct +# 09:45:32 00:00 0 300XXX 某某科技 快速拉升 25.80 +8.56 +# 09:52:18 00:00 0 002XXX 某某新材 大笔买入 33.60 +5.32 +# 10:05:44 00:00 0 000XXX 某某医药 封涨停板 18.90 +10.00 +# 10:12:07 00:00 0 300XXX 某某电子 快速下跌 12.45 -7.21 +# 10:23:55 00:00 0 002XXX 某某食品 大笔卖出 45.60 -3.45 +# ...(共20条) + +echo "=== 16. 获取全市场涨跌统计 ===" +# easy-tdx market-stat --table +# 输出: +#+------------+--------------+-----------------+-------------------+---------------+----------------+----------------+--------------------+------------------+--------------------+ +#| up_count | down_count | neutral_count | suspended_count | total_count | total_amount | total_volume | total_market_cap | limit_up_count | limit_down_count | +#+============+==============+=================+===================+===============+================+================+====================+==================+====================+ +#| 3869 | 1509 | 126 | 18 | 5522 | 2.92468e+12 | 1.35881e+09 | 1.1915e+14 | 136 | 18 | +#+------------+--------------+-----------------+-------------------+---------------+----------------+----------------+--------------------+------------------+--------------------+ + +echo "=== 17. 获取服务器交易时段信息 ===" +# easy-tdx server-info --table +# 输出: +# name start end status +# 早盘集合竞价 09:15 09:25 closed +# 早盘连续竞价 09:30 11:30 open +# 午盘连续竞价 13:00 15:00 open +# 盘后固定价格 15:05 15:30 closed + +echo "=== 18. 获取个股简要特征快照 ===" +# 获取个股简要特征快照。包含活跃度、内外盘、换手率、均价等。 +# MacSymbolInfo 字段: market, code, name, time, activity, pre_close, open, high, low, +# close, momentum, vol, amount, inside_volume, outside_volume, turnover, avg +# easy-tdx symbol-info SZ 000001 --table +# 输出: +# field value +# 代码 000001 +# 名称 平安银行 +# 市场 SZ +# 总股本(万) 1940521.84 +# 流通股(万) 1940521.84 +# 总市值(亿) 24509.37 +# 流通市值(亿) 24509.37 + +echo "=== 19. 列出扩展市场代码 ===" +# 列出所有可用的 ExMarket 枚举值和名称。 +# 常用: HK_MAIN_BOARD=31, US_STOCK=74, CFFEX_FUTURES=47, ZZ_FUTURES=28, DL_FUTURES=29 +# easy-tdx ex markets +# 输出: +# [ +# {"code": 1, "name": "TEMP_STOCK"}, +# {"code": 28, "name": "ZZ_FUTURES"}, +# {"code": 29, "name": "DL_FUTURES"}, +# {"code": 30, "name": "SH_FUTURES"}, +# {"code": 31, "name": "HK_MAIN_BOARD"}, +# {"code": 47, "name": "CFFEX_FUTURES"}, +# {"code": 48, "name": "HK_GEM"}, +# {"code": 74, "name": "US_STOCK"}, +# ... +# ] + +echo "=== 20. 获取扩展市场K线(港股腾讯)===" +# 获取扩展市场 K 线。参数: <代码> --count N --period <周期> +# 返回列: datetime, open, high, low, close, volume, amount +# easy-tdx ex kline HK_MAIN_BOARD 00700 --count 10 --table +# 输出: +# datetime open high low close volume amount +# 2025-05-12 00:00 520.0 528.0 518.5 525.0 16543000 8676540000 +# 2025-05-13 00:00 525.0 530.0 522.0 523.5 14321000 7498760000 +# 2025-05-14 00:00 523.5 527.0 520.0 525.5 13456000 7076540000 +# 2025-05-15 00:00 525.0 530.0 522.5 528.0 15234000 8011230000 +# 2025-05-16 00:00 528.0 532.0 525.5 530.5 12345000 6543210000 +# 2025-05-19 00:00 531.0 535.0 529.0 533.0 14567000 7765430000 +# 2025-05-20 00:00 533.0 536.0 530.0 531.5 11234000 5987650000 +# 2025-05-21 00:00 532.0 537.0 530.5 535.0 13456000 7187650000 +# ...(共10条) + +echo "=== 21. 获取扩展市场报价(美股苹果)===" +# 获取单只扩展市场股票报价。参数: <代码> +# 返回列: market, code, name, pre_close, open, high, low, price, volume, amount +# easy-tdx ex quote US_STOCK AAPL --table +# 输出: +# market code name pre_close open high low price volume amount +# 74 AAPL APPLE 213.75 214.00 218.00 213.50 217.50 49876000 10789650000 + +echo "=== 22. 获取扩展市场商品列表(港股主板)===" +# 获取扩展市场商品列表。参数: --count N +# 返回列: code(证券代码), name(证券名称), market(市场代码) +# easy-tdx ex quote-list HK_MAIN_BOARD --count 10 --table +# 输出: +# code name market +# 00001 长和 31 +# 00002 中电控股 31 +# 00003 香港中华煤气 31 +# 00004 九龙仓集团 31 +# 00005 汇丰控股 31 +# 00006 电能实业 31 +# 00007 高鑫零售 31 +# 00008 新鸿基地产 31 +# 00009 载通 31 +# 00010 恒隆地产 31 + +echo "=== 23. 获取扩展市场分时图(港股腾讯)===" +# 获取扩展市场当日分时走势。参数: <代码> +# 返回列: datetime, price, avg_price, volume +# easy-tdx ex tick HK_MAIN_BOARD 00700 --table +# 输出: +# datetime price avg_price volume +# 09:30:00 00:00 532.00 532.00 0 +# 09:31:00 00:00 532.50 532.25 5600 +# 09:32:00 00:00 533.00 532.50 3400 +# 09:33:00 00:00 533.50 532.75 2800 +# 09:34:00 00:00 532.80 532.56 4100 +# 09:35:00 00:00 533.20 532.84 3500 +# ...(共约330条) diff --git a/pyproject.toml b/pyproject.toml index 9650f07..29a8b21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,14 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.0.0" +version = "1.1.0" description = "通达信 TCP 协议行情数据客户端,支持在线行情与离线本地数据读取" readme = "README.md" requires-python = ">=3.10" -dependencies = ["pandas>=2.0", "tzdata>=2024.1"] +dependencies = ["pandas>=2.0", "tzdata>=2024.1", "click>=8.0"] + +[project.scripts] +easy-tdx = "easy_tdx.cli:cli" # cli/__init__.py exposes the click group [project.optional-dependencies] dev = ["pytest>=8.0", "pytest-cov", "mypy>=1.9", "ruff>=0.4"] diff --git a/src/easy_tdx/__init__.py b/src/easy_tdx/__init__.py index 1d5c198..b18db99 100644 --- a/src/easy_tdx/__init__.py +++ b/src/easy_tdx/__init__.py @@ -21,9 +21,22 @@ asyncio 版本:: """ from .client import AsyncTdxClient, TdxClient +from .config import save_best_ex_host, save_best_host from .ex.client import AsyncExTdxClient, ExTdxClient +from .ex.mac_client import AsyncMacExClient, MacExClient from .ex.models import KNOWN_EX_HOSTS from .exceptions import TdxCommandError, TdxConnectionError, TdxDecodeError, TdxError +from .mac.client import AsyncMacClient, MacClient +from .mac.enums import ( + Adjust, + BoardType, + Category, + ExMarket, + FilterType, + Period, + SortOrder, + SortType, +) from .models import ( XDXR_CATEGORY_NAMES, CompanyInfoCategory, @@ -39,15 +52,30 @@ from .models import ( TransactionRecord, XdxrRecord, ) -from .transport.sync import CALC_HOSTS, KNOWN_HOSTS, ping_all +from .transport.sync import CALC_HOSTS, KNOWN_HOSTS, MAC_HOSTS, ping_all, ping_mac_all +from .unified import AsyncUnifiedTdxClient, UnifiedTdxClient __all__ = [ # 客户端 "TdxClient", "AsyncTdxClient", + "MacClient", + "AsyncMacClient", + "MacExClient", + "AsyncMacExClient", + "UnifiedTdxClient", + "AsyncUnifiedTdxClient", # 枚举 "Market", "KlineCategory", + "Adjust", + "BoardType", + "Category", + "ExMarket", + "FilterType", + "Period", + "SortOrder", + "SortType", # 数据模型 "SecurityBar", "SecurityQuote", @@ -71,8 +99,12 @@ __all__ = [ "KNOWN_EX_HOSTS", # 工具 "ping_all", + "ping_mac_all", "KNOWN_HOSTS", "CALC_HOSTS", + "MAC_HOSTS", + "save_best_host", + "save_best_ex_host", ] __version__ = "1.0.0" diff --git a/src/easy_tdx/cli/__init__.py b/src/easy_tdx/cli/__init__.py new file mode 100644 index 0000000..263fce4 --- /dev/null +++ b/src/easy_tdx/cli/__init__.py @@ -0,0 +1,59 @@ +"""easy-tdx CLI -- Agent 友好的通达信行情命令行工具。""" + +from __future__ import annotations + +import click + +from .cmd_admin import ping, version +from .cmd_auction import auction +from .cmd_board import belong_board, board_list, board_members +from .cmd_capital import capital_flow +from .cmd_ex import ex +from .cmd_finance import f10, fund_flow +from .cmd_info import server_info, symbol_info +from .cmd_kline import kline +from .cmd_monitor import market_stat, unusual +from .cmd_quote import quote, quote_list +from .cmd_tick import tick +from .cmd_transaction import transaction + + +@click.group() +@click.version_option(version="1.1.0", prog_name="easy-tdx") +def cli() -> None: + """easy-tdx -- 通达信行情数据 CLI(默认 JSON 输出,适合 Agent 使用)。 + + 所有命令默认输出 JSON。使用 --table 切换为表格,--output 指定格式。 + + 示例: + + easy-tdx ping + + easy-tdx kline SZ 000001 --table + + easy-tdx quote "SZ 000001,SH 600519" + + easy-tdx quote-list A --count 20 --table + """ + pass + + +cli.add_command(ping) +cli.add_command(version) +cli.add_command(kline) +cli.add_command(quote) +cli.add_command(quote_list) +cli.add_command(tick) +cli.add_command(transaction) +cli.add_command(auction) +cli.add_command(board_list) +cli.add_command(board_members) +cli.add_command(belong_board) +cli.add_command(capital_flow) +cli.add_command(unusual) +cli.add_command(market_stat) +cli.add_command(server_info) +cli.add_command(symbol_info) +cli.add_command(f10) +cli.add_command(fund_flow) +cli.add_command(ex) diff --git a/src/easy_tdx/cli/cmd_admin.py b/src/easy_tdx/cli/cmd_admin.py new file mode 100644 index 0000000..4792585 --- /dev/null +++ b/src/easy_tdx/cli/cmd_admin.py @@ -0,0 +1,46 @@ +"""管理命令:ping, version。""" + +from __future__ import annotations + +import click + + +@click.command() +@click.option("--timeout", default=5.0, help="测速超时(秒)") +@click.option("--table", "use_table", is_flag=True, help="表格输出") +@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") +def ping(timeout: float, use_table: bool, output_fmt: str) -> None: + """测量通达信服务器延迟。 + + 示例: + + easy-tdx ping + + easy-tdx ping --timeout 3 --table + """ + import pandas as pd + + from ..transport.sync import ping_all, ping_mac_all + from .output import print_output + + fmt = "table" if use_table else output_fmt + + click.echo("正在测速标准服务器...", err=True) + std_results = ping_all(timeout=timeout) + click.echo("正在测速MAC服务器...", err=True) + mac_results = ping_mac_all(timeout=timeout) + + rows: list[dict[str, str | float]] = [] + for host, latency in std_results: + rows.append({"group": "standard", "host": host, "latency_ms": round(latency * 1000, 1)}) + for host, latency in mac_results: + rows.append({"group": "mac", "host": host, "latency_ms": round(latency * 1000, 1)}) + + df = pd.DataFrame(rows) + print_output(df, fmt) + + +@click.command() +def version() -> None: + """显示版本号。""" + click.echo("easy-tdx 1.1.0") diff --git a/src/easy_tdx/cli/cmd_auction.py b/src/easy_tdx/cli/cmd_auction.py new file mode 100644 index 0000000..df318d2 --- /dev/null +++ b/src/easy_tdx/cli/cmd_auction.py @@ -0,0 +1,30 @@ +"""集合竞价命令。""" + +from __future__ import annotations + +import click + + +@click.command() +@click.argument("market") +@click.argument("code") +@click.option("--table", "use_table", is_flag=True, help="表格输出") +@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") +def auction(market: str, code: str, use_table: bool, output_fmt: str) -> None: + """获取集合竞价数据。 + + 示例: + + easy-tdx auction SZ 000001 + + easy-tdx auction SH 600519 --table + """ + from .conn import get_mac_client + from .output import print_output + from .parsers import parse_market + + fmt = "table" if use_table else output_fmt + mkt = parse_market(market) + with get_mac_client() as client: + df = client.get_auction(mkt, code) + print_output(df, fmt) diff --git a/src/easy_tdx/cli/cmd_board.py b/src/easy_tdx/cli/cmd_board.py new file mode 100644 index 0000000..2101708 --- /dev/null +++ b/src/easy_tdx/cli/cmd_board.py @@ -0,0 +1,106 @@ +"""板块命令:board-list, board-members, belong-board。""" + +from __future__ import annotations + +import click + + +@click.command("board-list") +@click.option("--type", "board_type", default="ALL", help="板块类型: ALL/HY/GN/FG/DQ/OTHER") +@click.option("--count", default=10000, type=int, help="请求数量") +@click.option("--table", "use_table", is_flag=True, help="表格输出") +@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") +def board_list( + board_type: str, + count: int, + use_table: bool, + output_fmt: str, +) -> None: + """获取板块列表。 + + 示例: + + easy-tdx board-list --table + + easy-tdx board-list --type GN --count 200 + + easy-tdx board-list --type HY + """ + from .conn import get_mac_client + from .output import print_output + from .parsers import parse_board_type + + fmt = "table" if use_table else output_fmt + bt = parse_board_type(board_type) + with get_mac_client() as client: + df = client.get_board_list(board_type=bt, count=count) + print_output(df, fmt) + + +@click.command("board-members") +@click.argument("board_symbol") +@click.option("--count", default=100000, type=int, help="请求数量") +@click.option( + "--sort", "sort_field", default="CHANGE_PCT", help="排序字段: CHANGE_PCT/CODE/PRICE/VOLUME" +) +@click.option("--order", "sort_order", default="DESC", help="排序方向: DESC/ASC") +@click.option("--table", "use_table", is_flag=True, help="表格输出") +@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") +def board_members( + board_symbol: str, + count: int, + sort_field: str, + sort_order: str, + use_table: bool, + output_fmt: str, +) -> None: + """获取板块成分股报价。 + + BOARD_SYMBOL: 板块代码(如 881001) + + 示例: + + easy-tdx board-members 881001 --table + + easy-tdx board-members 881001 --sort VOLUME --count 20 + """ + from .conn import get_mac_client + from .output import print_output + from .parsers import parse_sort_order, parse_sort_type + + fmt = "table" if use_table else output_fmt + st = parse_sort_type(sort_field) + so = parse_sort_order(sort_order) + with get_mac_client() as client: + df = client.get_board_members( + board_symbol, + count=count, + sort_type=st, + sort_order=so, + ) + print_output(df, fmt) + + +@click.command("belong-board") +@click.argument("market") +@click.argument("code") +@click.option("--table", "use_table", is_flag=True, help="表格输出") +@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") +def belong_board(market: str, code: str, use_table: bool, output_fmt: str) -> None: + """获取个股所属板块列表。 + + 示例: + + easy-tdx belong-board SZ 000001 + + easy-tdx belong-board SH 600519 --table + """ + from .conn import get_mac_client + from .output import print_output + from .parsers import parse_market + + fmt = "table" if use_table else output_fmt + mkt = parse_market(market) + with get_mac_client() as client: + df = client.get_belong_board(mkt, code) + print_output(df, fmt) diff --git a/src/easy_tdx/cli/cmd_capital.py b/src/easy_tdx/cli/cmd_capital.py new file mode 100644 index 0000000..bafad60 --- /dev/null +++ b/src/easy_tdx/cli/cmd_capital.py @@ -0,0 +1,30 @@ +"""资金流向命令。""" + +from __future__ import annotations + +import click + + +@click.command("capital-flow") +@click.argument("market") +@click.argument("code") +@click.option("--table", "use_table", is_flag=True, help="表格输出") +@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") +def capital_flow(market: str, code: str, use_table: bool, output_fmt: str) -> None: + """获取个股资金流向数据。 + + 示例: + + easy-tdx capital-flow SZ 000001 + + easy-tdx capital-flow SH 600519 --table + """ + from .conn import get_mac_client + from .output import print_output + from .parsers import parse_market + + fmt = "table" if use_table else output_fmt + mkt = parse_market(market) + with get_mac_client() as client: + df = client.get_capital_flow(mkt, code) + print_output(df, fmt) diff --git a/src/easy_tdx/cli/cmd_ex.py b/src/easy_tdx/cli/cmd_ex.py new file mode 100644 index 0000000..067c932 --- /dev/null +++ b/src/easy_tdx/cli/cmd_ex.py @@ -0,0 +1,177 @@ +"""扩展市场命令(期货/港股/美股)。""" + +from __future__ import annotations + +import click + + +@click.group() +def ex() -> None: + """扩展市场命令(期货/港股/美股)。 + + 示例: + + easy-tdx ex kline HK_MAIN_BOARD 00700 --count 30 + + easy-tdx ex quote US_STOCK AAPL + + easy-tdx ex markets + """ + pass + + +@ex.command() +@click.argument("market") +@click.argument("code") +@click.option("--period", default="DAILY", help="K线周期: DAILY/5MIN/15MIN/30MIN/60MIN/1MIN") +@click.option("--count", default=800, type=int, help="K线数量") +@click.option("--start", default=0, type=int, help="起始偏移") +@click.option("--adjust", default="NONE", help="复权: NONE/QFQ/HFQ") +@click.option("--table", "use_table", is_flag=True, help="表格输出") +@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") +def kline( + market: str, + code: str, + period: str, + count: int, + start: int, + adjust: str, + use_table: bool, + output_fmt: str, +) -> None: + """获取扩展市场 K 线数据。 + + MARKET: 扩展市场代码(如 HK_MAIN_BOARD, US_STOCK, SH_FUTURES) + + 示例: + + easy-tdx ex kline HK_MAIN_BOARD 00700 + + easy-tdx ex kline US_STOCK AAPL --count 30 --table + """ + from .conn import get_mac_ex_client + from .output import print_output + from .parsers import parse_adjust, parse_ex_market, parse_period + + fmt = "table" if use_table else output_fmt + mkt = parse_ex_market(market) + with get_mac_ex_client() as client: + df = client.goods_kline( + mkt, code, + period=parse_period(period), + start=start, + count=count, + adjust=parse_adjust(adjust), + ) + print_output(df, fmt) + + +@ex.command() +@click.argument("market") +@click.argument("code") +@click.option("--table", "use_table", is_flag=True, help="表格输出") +@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") +def quote(market: str, code: str, use_table: bool, output_fmt: str) -> None: + """获取扩展市场报价。 + + MARKET: 扩展市场代码 + + 示例: + + easy-tdx ex quote HK_MAIN_BOARD 00700 + + easy-tdx ex quote US_STOCK AAPL --table + """ + from .conn import get_mac_ex_client + from .output import print_output + from .parsers import parse_ex_market + + fmt = "table" if use_table else output_fmt + mkt = parse_ex_market(market) + with get_mac_ex_client() as client: + df = client.goods_quotes([(mkt, code)]) + print_output(df, fmt) + + +@ex.command("quote-list") +@click.argument("market") +@click.option("--count", default=600, type=int, help="请求数量") +@click.option("--start", default=0, type=int, help="起始偏移") +@click.option("--table", "use_table", is_flag=True, help="表格输出") +@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") +def quote_list( + market: str, + count: int, + start: int, + use_table: bool, + output_fmt: str, +) -> None: + """获取扩展市场商品列表。 + + MARKET: 扩展市场代码(如 HK_MAIN_BOARD, US_STOCK, SH_FUTURES) + + 示例: + + easy-tdx ex quote-list HK_MAIN_BOARD --table + + easy-tdx ex quote-list SH_FUTURES --count 100 + """ + from .conn import get_mac_ex_client + from .output import print_output + from .parsers import parse_ex_market + + fmt = "table" if use_table else output_fmt + mkt = parse_ex_market(market) + with get_mac_ex_client() as client: + df = client.goods_list(mkt, start=start, count=count) + print_output(df, fmt) + + +@ex.command() +@click.argument("market") +@click.argument("code") +@click.option("--date", default=None, type=int, help="日期 YYYYMMDD(默认今天)") +@click.option("--days", default=1, type=int, help="天数(1或5)") +@click.option("--table", "use_table", is_flag=True, help="表格输出") +@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") +def tick( + market: str, + code: str, + date: int | None, + days: int, + use_table: bool, + output_fmt: str, +) -> None: + """获取扩展市场分时数据。 + + 示例: + + easy-tdx ex tick HK_MAIN_BOARD 00700 + + easy-tdx ex tick US_STOCK AAPL --table + """ + from .conn import get_mac_ex_client + from .output import print_output + from .parsers import parse_ex_market + + fmt = "table" if use_table else output_fmt + mkt = parse_ex_market(market) + with get_mac_ex_client() as client: + df = client.goods_tick_chart(mkt, code, query_date=date) # type: ignore[arg-type] + print_output(df, fmt) + + +@ex.command("markets") +def markets() -> None: + """列出可用的扩展市场代码。""" + import pandas as pd + + from ..mac.enums import ExMarket + from .output import print_output + + rows: list[dict[str, int | str]] = [] + for m in ExMarket: + rows.append({"code": m.value, "name": m.name}) + + df = pd.DataFrame(rows) + print_output(df, "json") diff --git a/src/easy_tdx/cli/cmd_finance.py b/src/easy_tdx/cli/cmd_finance.py new file mode 100644 index 0000000..c12c00c --- /dev/null +++ b/src/easy_tdx/cli/cmd_finance.py @@ -0,0 +1,33 @@ +"""财务数据命令(暂未实现)。""" + +from __future__ import annotations + +import click + + +@click.command("f10") +@click.argument("market") +@click.argument("code") +def f10(market: str, code: str) -> None: + """获取 F10 财务数据(暂未实现)。 + + 示例: + + easy-tdx f10 SZ 000001 + """ + raise click.UsageError("f10 命令暂未实现,请使用 TdxClient.get_finance_info() API") + + +@click.command("fund-flow") +@click.argument("market") +@click.argument("code") +@click.option("--start", default=0, type=int, help="起始偏移") +@click.option("--count", default=30, type=int, help="请求数量") +def fund_flow(market: str, code: str, start: int, count: int) -> None: + """获取历史资金流向(暂未实现)。 + + 示例: + + easy-tdx fund-flow SZ 000001 + """ + raise click.UsageError("fund-flow 命令暂未实现,请使用 TdxClient.get_history_fund_flow() API") diff --git a/src/easy_tdx/cli/cmd_info.py b/src/easy_tdx/cli/cmd_info.py new file mode 100644 index 0000000..3be9637 --- /dev/null +++ b/src/easy_tdx/cli/cmd_info.py @@ -0,0 +1,51 @@ +"""信息查询命令:server-info, symbol-info。""" + +from __future__ import annotations + +import click + + +@click.command("server-info") +@click.option("--table", "use_table", is_flag=True, help="表格输出") +@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") +def server_info(use_table: bool, output_fmt: str) -> None: + """获取服务器交易时段信息。 + + 示例: + + easy-tdx server-info + + easy-tdx server-info --table + """ + from .conn import get_mac_client + from .output import print_output + + fmt = "table" if use_table else output_fmt + with get_mac_client() as client: + df = client.get_server_info() + print_output(df, fmt) + + +@click.command("symbol-info") +@click.argument("market") +@click.argument("code") +@click.option("--table", "use_table", is_flag=True, help="表格输出") +@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") +def symbol_info(market: str, code: str, use_table: bool, output_fmt: str) -> None: + """获取个股简要特征快照。 + + 示例: + + easy-tdx symbol-info SZ 000001 + + easy-tdx symbol-info SH 600519 --table + """ + from .conn import get_mac_client + from .output import print_output + from .parsers import parse_market + + fmt = "table" if use_table else output_fmt + mkt = parse_market(market) + with get_mac_client() as client: + df = client.get_symbol_info(mkt, code) + print_output(df, fmt) diff --git a/src/easy_tdx/cli/cmd_kline.py b/src/easy_tdx/cli/cmd_kline.py new file mode 100644 index 0000000..f43ced6 --- /dev/null +++ b/src/easy_tdx/cli/cmd_kline.py @@ -0,0 +1,54 @@ +"""K 线命令。""" + +from __future__ import annotations + +import click + + +@click.command() +@click.argument("market") +@click.argument("code") +@click.option( + "--period", default="DAILY", help="K线周期: DAILY/5MIN/15MIN/30MIN/60MIN/1MIN/WEEKLY/MONTHLY" +) +@click.option("--count", default=800, type=int, help="K线数量") +@click.option("--start", default=0, type=int, help="起始偏移(0=最新)") +@click.option("--adjust", default="NONE", help="复权: NONE/QFQ/HFQ") +@click.option("--table", "use_table", is_flag=True, help="表格输出") +@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") +def kline( + market: str, + code: str, + period: str, + count: int, + start: int, + adjust: str, + use_table: bool, + output_fmt: str, +) -> None: + """获取 K 线数据。 + + 示例: + + easy-tdx kline SZ 000001 + + easy-tdx kline SH 600519 --adjust QFQ --count 30 + + easy-tdx kline SZ 000001 --period 5MIN --table + """ + from .conn import get_mac_client + from .output import print_output + from .parsers import parse_adjust, parse_market, parse_period + + fmt = "table" if use_table else output_fmt + mkt = parse_market(market) + with get_mac_client() as client: + df = client.get_stock_kline( + mkt, + code, + period=parse_period(period), + start=start, + count=count, + adjust=parse_adjust(adjust), + ) + print_output(df, fmt) diff --git a/src/easy_tdx/cli/cmd_monitor.py b/src/easy_tdx/cli/cmd_monitor.py new file mode 100644 index 0000000..bcd863d --- /dev/null +++ b/src/easy_tdx/cli/cmd_monitor.py @@ -0,0 +1,58 @@ +"""市场监控命令:unusual, market-stat。""" + +from __future__ import annotations + +import click + + +@click.command() +@click.argument("market") +@click.option("--count", default=600, type=int, help="请求数量") +@click.option("--start", default=0, type=int, help="起始偏移") +@click.option("--table", "use_table", is_flag=True, help="表格输出") +@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") +def unusual( + market: str, + count: int, + start: int, + use_table: bool, + output_fmt: str, +) -> None: + """获取市场异动数据。 + + 示例: + + easy-tdx unusual SZ + + easy-tdx unusual SH --count 100 --table + """ + from .conn import get_mac_client + from .output import print_output + from .parsers import parse_market + + fmt = "table" if use_table else output_fmt + mkt = parse_market(market) + with get_mac_client() as client: + df = client.get_unusual(mkt, start=start, count=count) + print_output(df, fmt) + + +@click.command("market-stat") +@click.option("--table", "use_table", is_flag=True, help="表格输出") +@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") +def market_stat(use_table: bool, output_fmt: str) -> None: + """获取 A 股全市场涨跌统计概况。 + + 示例: + + easy-tdx market-stat + + easy-tdx market-stat --table + """ + from ..client import TdxClient + from .output import print_output + + fmt = "table" if use_table else output_fmt + with TdxClient.from_best_host() as client: + df = client.get_market_stat() + print_output(df, fmt) diff --git a/src/easy_tdx/cli/cmd_quote.py b/src/easy_tdx/cli/cmd_quote.py new file mode 100644 index 0000000..0507d4a --- /dev/null +++ b/src/easy_tdx/cli/cmd_quote.py @@ -0,0 +1,81 @@ +"""报价命令:quote(单/批量), quote-list(按分类排序)。""" + +from __future__ import annotations + +import click + + +@click.command() +@click.argument("stocks") +@click.option("--table", "use_table", is_flag=True, help="表格输出") +@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") +def quote(stocks: str, use_table: bool, output_fmt: str) -> None: + """获取实时报价(支持多只)。 + + STOCKS 格式: "SZ 000001,SH 600519" + + 示例: + + easy-tdx quote "SZ 000001" + + easy-tdx quote "SZ 000001,SH 600519" --table + """ + from .conn import get_mac_client + from .output import print_output + from .parsers import parse_stocks + + fmt = "table" if use_table else output_fmt + stock_list = parse_stocks(stocks) + with get_mac_client() as client: + df = client.get_stock_quotes(stock_list) + print_output(df, fmt) + + +@click.command("quote-list") +@click.argument("category", default="A") +@click.option("--count", default=80, type=int, help="请求数量") +@click.option( + "--sort", + "sort_field", + default="CHANGE_PCT", + help="排序字段: CHANGE_PCT/CODE/PRICE/VOLUME/TOTAL_AMOUNT/TURNOVER_RATE", +) +@click.option("--order", "sort_order", default="DESC", help="排序方向: DESC/ASC") +@click.option("--table", "use_table", is_flag=True, help="表格输出") +@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") +def quote_list( + category: str, + count: int, + sort_field: str, + sort_order: str, + use_table: bool, + output_fmt: str, +) -> None: + """获取市场分类报价列表(按涨幅等排序)。 + + CATEGORY: SH/SZ/A/B/KCB/BJ/CYB/ETF/LOF/HGT/SGT 等 + + 示例: + + easy-tdx quote-list A --count 20 --table + + easy-tdx quote-list KCB --sort TOTAL_AMOUNT --order ASC + + easy-tdx quote-list CYB --count 50 + """ + from .conn import get_mac_client + from .output import print_output + from .parsers import parse_category, parse_sort_order, parse_sort_type + + fmt = "table" if use_table else output_fmt + cat = parse_category(category) + st = parse_sort_type(sort_field) + so = parse_sort_order(sort_order) + with get_mac_client() as client: + df = client.get_stock_quotes_list( + category=cat, + count=count, + sort_type=st, + sort_order=so, + ) + print_output(df, fmt) diff --git a/src/easy_tdx/cli/cmd_tick.py b/src/easy_tdx/cli/cmd_tick.py new file mode 100644 index 0000000..ecc2207 --- /dev/null +++ b/src/easy_tdx/cli/cmd_tick.py @@ -0,0 +1,44 @@ +"""分时图命令。""" + +from __future__ import annotations + +import click + + +@click.command() +@click.argument("market") +@click.argument("code") +@click.option("--date", default=None, type=int, help="日期 YYYYMMDD(默认今天)") +@click.option("--days", default=1, type=int, help="天数(1或5)") +@click.option("--table", "use_table", is_flag=True, help="表格输出") +@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") +def tick( + market: str, + code: str, + date: int | None, + days: int, + use_table: bool, + output_fmt: str, +) -> None: + """获取分时图数据。 + + 示例: + + easy-tdx tick SZ 000001 + + easy-tdx tick SH 600519 --days 5 --table + + easy-tdx tick SZ 000001 --date 20250115 + """ + from .conn import get_mac_client + from .output import print_output + from .parsers import parse_market + + fmt = "table" if use_table else output_fmt + mkt = parse_market(market) + with get_mac_client() as client: + if days > 1: + df = client.get_tick_charts(mkt, code, date=date, days=days) + else: + df = client.get_tick_chart(mkt, code, date=date) + print_output(df, fmt) diff --git a/src/easy_tdx/cli/cmd_transaction.py b/src/easy_tdx/cli/cmd_transaction.py new file mode 100644 index 0000000..93fcef3 --- /dev/null +++ b/src/easy_tdx/cli/cmd_transaction.py @@ -0,0 +1,43 @@ +"""逐笔成交命令。""" + +from __future__ import annotations + +import click + + +@click.command() +@click.argument("market") +@click.argument("code") +@click.option("--count", default=2000, type=int, help="请求数量") +@click.option("--start", default=0, type=int, help="起始偏移") +@click.option("--date", default=None, type=int, help="日期 YYYYMMDD(默认今天)") +@click.option("--table", "use_table", is_flag=True, help="表格输出") +@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") +def transaction( + market: str, + code: str, + count: int, + start: int, + date: int | None, + use_table: bool, + output_fmt: str, +) -> None: + """获取逐笔成交数据。 + + 示例: + + easy-tdx transaction SZ 000001 + + easy-tdx transaction SH 600519 --count 500 --table + + easy-tdx transaction SZ 000001 --date 20250115 + """ + from .conn import get_mac_client + from .output import print_output + from .parsers import parse_market + + fmt = "table" if use_table else output_fmt + mkt = parse_market(market) + with get_mac_client() as client: + df = client.get_transactions(mkt, code, count=count, start=start, date=date) + print_output(df, fmt) diff --git a/src/easy_tdx/cli/conn.py b/src/easy_tdx/cli/conn.py new file mode 100644 index 0000000..4259da6 --- /dev/null +++ b/src/easy_tdx/cli/conn.py @@ -0,0 +1,37 @@ +"""CLI 连接工厂:延迟创建 MAC 客户端。""" + +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager + +from ..ex.mac_client import MacExClient +from ..mac.client import MacClient + + +@contextmanager +def get_mac_client() -> Generator[MacClient, None, None]: + """创建 MAC 客户端上下文(自动选最快服务器)。 + + 使用方式:: + + with get_mac_client() as client: + df = client.get_stock_kline(...) + """ + client = MacClient.from_best_host() + try: + client.connect() + yield client + finally: + client.close() + + +@contextmanager +def get_mac_ex_client() -> Generator[MacExClient, None, None]: + """创建扩展市场 MAC 客户端上下文(端口 7727)。""" + client = MacExClient.from_best_host() + try: + client.connect() + yield client + finally: + client.close() diff --git a/src/easy_tdx/cli/output.py b/src/easy_tdx/cli/output.py new file mode 100644 index 0000000..36b897d --- /dev/null +++ b/src/easy_tdx/cli/output.py @@ -0,0 +1,60 @@ +"""CLI 输出格式化:JSON(默认)、表格、CSV。""" + +from __future__ import annotations + +import click +import pandas as pd + + +def format_output(df: pd.DataFrame, fmt: str = "json") -> str: + """将 DataFrame 格式化为指定输出格式。""" + if df.empty: + return "[]" if fmt == "json" else "" + + if fmt == "json": + result: str = df.to_json(orient="records", force_ascii=False, date_format="iso") + return result + if fmt == "csv": + return str(df.to_csv(index=False)) + if fmt == "table": + return _render_table(df) + raise click.UsageError(f"不支持的输出格式: {fmt}") + + +def print_output(df: pd.DataFrame, fmt: str = "json") -> None: + """格式化并输出 DataFrame 到 stdout。""" + text = format_output(df, fmt) + if text: + click.echo(text) + + +def print_error(msg: str) -> None: + """输出错误消息到 stderr。""" + click.echo(f"错误: {msg}", err=True) + + +def _render_table(df: pd.DataFrame) -> str: + """将 DataFrame 渲染为人类可读的文本表格。""" + if df.empty: + return "(无数据)" + + display_df = df.copy() + for col in display_df.columns: + if display_df[col].dtype == object: + display_df[col] = display_df[col].astype(str).str.slice(0, 30) + + try: + import tabulate + + return str(tabulate.tabulate(display_df, headers="keys", tablefmt="grid", showindex=False)) + except ImportError: + lines: list[str] = [] + cols = list(display_df.columns) + header = " | ".join(str(c) for c in cols) + sep = "-+-".join("-" * min(len(str(c)), 30) for c in cols) + lines.append(header) + lines.append(sep) + for _, row in display_df.iterrows(): + line = " | ".join(str(v)[:30] for v in row.values) + lines.append(line) + return "\n".join(lines) diff --git a/src/easy_tdx/cli/parsers.py b/src/easy_tdx/cli/parsers.py new file mode 100644 index 0000000..a2da55b --- /dev/null +++ b/src/easy_tdx/cli/parsers.py @@ -0,0 +1,188 @@ +"""CLI 参数解析工具。""" + +from __future__ import annotations + +import click + +from ..mac.enums import ( + Adjust, + BoardType, + Category, + ExMarket, + Period, + SortOrder, + SortType, +) +from ..models.enums import Market + +_MARKET_MAP: dict[str, Market] = { + "SZ": Market.SZ, + "SH": Market.SH, + "BJ": Market.BJ, + "0": Market.SZ, + "1": Market.SH, + "2": Market.BJ, +} + + +def parse_market(s: str) -> int: + """Parse market string to int value. Accepts 'SZ', 'SH', 'BJ', '0', '1', '2'.""" + s_upper = s.upper() + if s_upper in _MARKET_MAP: + return _MARKET_MAP[s_upper] + return int(s) + + +_PERIOD_MAP: dict[str, Period] = { + "1MIN": Period.MIN_1, + "1": Period.MIN_1, + "5MIN": Period.MIN_5, + "5": Period.MIN_5, + "15MIN": Period.MIN_15, + "15": Period.MIN_15, + "30MIN": Period.MIN_30, + "30": Period.MIN_30, + "60MIN": Period.MIN_60, + "60": Period.MIN_60, + "DAILY": Period.DAILY, + "D": Period.DAILY, + "WEEKLY": Period.WEEKLY, + "W": Period.WEEKLY, + "MONTHLY": Period.MONTHLY, + "M": Period.MONTHLY, + "YEARLY": Period.YEARLY, + "Y": Period.YEARLY, +} + + +def parse_period(s: str) -> Period: + """Parse period string.""" + s_upper = s.upper() + if s_upper in _PERIOD_MAP: + return _PERIOD_MAP[s_upper] + return Period(int(s)) + + +_ADJUST_MAP: dict[str, Adjust] = { + "NONE": Adjust.NONE, + "0": Adjust.NONE, + "QFQ": Adjust.QFQ, + "1": Adjust.QFQ, + "FQ": Adjust.QFQ, + "HFQ": Adjust.HFQ, + "2": Adjust.HFQ, +} + + +def parse_adjust(s: str) -> Adjust: + """Parse adjust string.""" + s_upper = s.upper() + if s_upper in _ADJUST_MAP: + return _ADJUST_MAP[s_upper] + return Adjust(int(s)) + + +_BOARD_TYPE_MAP: dict[str, BoardType] = { + "HY": BoardType.HY, + "INDUSTRY": BoardType.HY, + "GN": BoardType.GN, + "CONCEPT": BoardType.GN, + "FG": BoardType.FG, + "STYLE": BoardType.FG, + "DQ": BoardType.DQ, + "REGION": BoardType.DQ, + "ALL": BoardType.ALL, +} + + +def parse_board_type(s: str) -> BoardType: + """Parse board type string.""" + s_upper = s.upper() + if s_upper in _BOARD_TYPE_MAP: + return _BOARD_TYPE_MAP[s_upper] + return BoardType(int(s)) + + +def parse_ex_market(s: str) -> int: + """Parse extended market string to int value.""" + s_upper = s.upper() + for member in ExMarket: + if member.name == s_upper: + return member.value + _EX_MAP: dict[str, ExMarket] = { + "HK": ExMarket.HK_MAIN_BOARD, + "HK_MAIN_BOARD": ExMarket.HK_MAIN_BOARD, + "US": ExMarket.US_STOCK, + "US_STOCK": ExMarket.US_STOCK, + "SH_FUTURES": ExMarket.SH_FUTURES, + "DCE": ExMarket.DL_FUTURES, + "CZCE": ExMarket.ZZ_FUTURES, + "CFFEX": ExMarket.CFFEX_FUTURES, + "INE": ExMarket.SH_GOLD, + "GFEX": ExMarket.GZ_FUTURES, + } + if s_upper in _EX_MAP: + return _EX_MAP[s_upper].value + return int(s) + + +_CATEGORY_MAP: dict[str, Category] = { + "A": Category.A, + "全A": Category.A, + "B": Category.B, + "KCB": Category.KCB, + "CYB": Category.CYB, + "BJ": Category.BJ, + "SH": Category.SH, + "SZ": Category.SZ, +} + + +def parse_category(s: str) -> Category: + """Parse category string to Category enum.""" + s_upper = s.upper() + for member in Category: + if member.name == s_upper: + return member + if s_upper in _CATEGORY_MAP: + return _CATEGORY_MAP[s_upper] + return Category(int(s)) + + +def parse_sort_type(s: str) -> SortType: + """Parse sort type string.""" + s_upper = s.upper() + for member in SortType: + if member.name == s_upper: + return member + return SortType(int(s)) + + +_SORT_ORDER_MAP: dict[str, SortOrder] = { + "ASC": SortOrder.ASC, + "DESC": SortOrder.DESC, + "NONE": SortOrder.NONE, +} + + +def parse_sort_order(s: str) -> SortOrder: + """Parse sort order string.""" + s_upper = s.upper() + if s_upper in _SORT_ORDER_MAP: + return _SORT_ORDER_MAP[s_upper] + return SortOrder(int(s)) + + +def parse_stocks(s: str) -> list[tuple[int, str]]: + """Parse stock list like 'SZ 000001,SH 600000' into [(0, '000001'), (1, '600000')].""" + result: list[tuple[int, str]] = [] + for pair in s.split(","): + pair = pair.strip() + parts = pair.split() + if len(parts) == 2: + market = parse_market(parts[0]) + code = parts[1] + result.append((market, code)) + elif len(parts) == 1: + click.echo(f"Warning: skipping ambiguous stock spec '{pair}'", err=True) + return result diff --git a/src/easy_tdx/client.py b/src/easy_tdx/client.py index 737f9fd..8cca5dc 100644 --- a/src/easy_tdx/client.py +++ b/src/easy_tdx/client.py @@ -3,12 +3,13 @@ import asyncio import json import logging +import time from collections.abc import Awaitable, Callable from dataclasses import asdict from datetime import datetime from pathlib import Path from types import TracebackType -from typing import TypeVar +from typing import Any, TypeVar from zoneinfo import ZoneInfo import pandas as pd @@ -31,6 +32,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 .config import get_best_host, get_calc_hosts, get_known_hosts, get_port, get_timeout, save_best_host from .exceptions import TdxConnectionError from .models.bar import SecurityBar from .models.enums import KlineCategory, Market @@ -42,9 +44,9 @@ from .models.security import SecurityInfo from .models.stats import FundFlow, HistoricalFundFlow, MarketStat from .models.timeseries import TransactionRecord from .transport.async_ import AsyncTdxConnection -from .transport.sync import CALC_HOSTS, KNOWN_HOSTS, TdxConnection, ping_all +from .transport.sync import TdxConnection, ping_all -_DEFAULT_PORT = 7709 +_RETRY_DELAYS = (0.1, 0.5, 1.0, 2.0) _T = TypeVar("_T") _SHANGHAI_TZ = ZoneInfo("Asia/Shanghai") _DAILY_PLUS = frozenset( @@ -145,11 +147,11 @@ _CACHE_DIR = Path.home() / ".easy_tdx" / "cache" _CACHE_MAX_AGE = 86400 # 1 天 -def _serialize_stocks(stocks: list[SecurityInfo]) -> list[dict]: +def _serialize_stocks(stocks: list[SecurityInfo]) -> list[dict[str, Any]]: 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]: +def _deserialize_stocks(data: list[dict[str, Any]]) -> list[SecurityInfo]: return [SecurityInfo(**{**d, "market": Market(d["market"])}) for d in data] @@ -195,15 +197,17 @@ class TdxClient: def __init__( self, - host: str = KNOWN_HOSTS[0], - port: int = _DEFAULT_PORT, - timeout: float = 15.0, + host: str | None = None, + port: int | None = None, + timeout: float | None = None, auto_reconnect: bool = True, + heartbeat_interval: float = 15.0, ) -> None: - self._host = host - self._port = port - self._timeout = timeout + self._host = host if host is not None else get_best_host() + self._port = port if port is not None else get_port() + self._timeout = timeout if timeout is not None else get_timeout() self._auto_reconnect = auto_reconnect + self._heartbeat_interval = heartbeat_interval self._conn = TdxConnection(host, port, timeout) # ------------------------------------------------------------------ # @@ -213,27 +217,40 @@ class TdxClient: @classmethod def from_best_host( cls, - hosts: list[str] = KNOWN_HOSTS, - port: int = _DEFAULT_PORT, - timeout: float = 15.0, + hosts: list[str] | None = None, + port: int | None = None, + timeout: float | None = None, ping_timeout: float = 5.0, auto_reconnect: bool = True, + heartbeat_interval: float = 15.0, ) -> "TdxClient": """测量 hosts 中所有服务器延迟,选最低延迟的建立连接。 + 自动将最佳主机保存到 config.json,后续连接默认使用该主机。 若所有服务器均不可达,回退到 hosts[0]。 """ + if hosts is None: + hosts = get_known_hosts() + if port is None: + port = get_port() + if timeout is None: + timeout = get_timeout() ranked = ping_all(hosts, port, ping_timeout) best = ranked[0][0] if ranked else hosts[0] - return cls(best, port, timeout, auto_reconnect) + save_best_host(best) + return cls(best, port, timeout, auto_reconnect, heartbeat_interval) @staticmethod def ping_all( - hosts: list[str] = KNOWN_HOSTS, - port: int = _DEFAULT_PORT, + hosts: list[str] | None = None, + port: int | None = None, timeout: float = 5.0, ) -> list[tuple[str, float]]: """测量多台服务器延迟,返回按延迟排序的 (host, seconds) 列表。""" + if hosts is None: + hosts = get_known_hosts() + if port is None: + port = get_port() return ping_all(hosts, port, timeout) # ------------------------------------------------------------------ # @@ -242,10 +259,29 @@ class TdxClient: def connect(self) -> None: self._conn.connect() + if self._heartbeat_interval > 0: + self._conn.start_heartbeat(self._heartbeat_interval) def close(self) -> None: + self._conn.stop_heartbeat() self._conn.close() + def disconnect(self) -> None: + """Alias for close().""" + self.close() + + def ensure_connected(self) -> None: + """验证连接存活,断线则自动重建。""" + try: + self._execute(GetSecurityCountCmd(Market.SH)) + except TdxConnectionError: + self._conn.stop_heartbeat() + self._conn.close() + self._conn = TdxConnection(self._host, self._port, self._timeout) + self._conn.connect() + if self._heartbeat_interval > 0: + self._conn.start_heartbeat(self._heartbeat_interval) + def __enter__(self) -> "TdxClient": self.connect() return self @@ -263,17 +299,25 @@ class TdxClient: # ------------------------------------------------------------------ # 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) + last_exc: TdxConnectionError | None = None + for delay in _RETRY_DELAYS: + time.sleep(delay) + self._conn.close() + self._conn = TdxConnection(self._host, self._port, self._timeout) + self._conn.connect() + if self._heartbeat_interval > 0: + self._conn.start_heartbeat(self._heartbeat_interval) + try: + return self._conn.execute(cmd) + except TdxConnectionError as e: + last_exc = e + raise last_exc # type: ignore[misc] # ------------------------------------------------------------------ # # 市场信息 @@ -525,29 +569,35 @@ class TdxClient: finally: conn.close() - def get_financial_file_list(self, host: str = CALC_HOSTS[0]) -> pd.DataFrame: + def get_financial_file_list(self, host: str | None = None) -> pd.DataFrame: """获取可用的历史专业财报文件列表。 连接到计算服务器,下载 tdxfin/gpcw.txt 并解析。 """ + if host is None: + host = get_calc_hosts()[0] data = self._download_from_host(host, "tdxfin/gpcw.txt") raw_list = parse_financial_file_list(data) return _to_df([FinancialFileInfo(filename=f, hash=h, filesize=s) for f, h, s in raw_list]) - def get_financial_file(self, filename: str, host: str = CALC_HOSTS[0]) -> bytes: + def get_financial_file(self, filename: str, host: str | None = None) -> bytes: """从计算服务器下载财报 zip 文件。 Args: filename: 如 'tdxfin/gpcw20260331.zip' """ + if host is None: + host = get_calc_hosts()[0] return self._download_from_host(host, filename) - def get_financial_records(self, filename: str, host: str = CALC_HOSTS[0]) -> pd.DataFrame: + def get_financial_records(self, filename: str, host: str | None = None) -> pd.DataFrame: """下载财报 zip 并解析为每只股票的记录列表。 Args: filename: 如 'tdxfin/gpcw20260331.zip' """ + if host is None: + host = get_calc_hosts()[0] import io import re import zipfile @@ -568,7 +618,7 @@ class TdxClient: raw_records = parse_financial_dat(dat_data, report_date) records: list[FinancialRecord] = [] for code, market_byte, rdate, fields in raw_records: - market = Market.SH if market_byte == b"\x01" else Market.SZ + market = Market.SH if market_byte == 1 else Market.SZ records.append( FinancialRecord(code=code, market=market, report_date=rdate, fields=fields) ) @@ -713,43 +763,57 @@ class AsyncTdxClient: def __init__( self, - host: str = KNOWN_HOSTS[0], - port: int = _DEFAULT_PORT, - timeout: float = 15.0, + host: str | None = None, + port: int | None = None, + timeout: float | None = None, auto_reconnect: bool = True, heartbeat_interval: float = 60.0, ) -> None: - self._host = host - self._port = port - self._timeout = timeout + self._host = host if host is not None else get_best_host() + self._port = port if port is not None else get_port() + self._timeout = timeout if timeout is not None else get_timeout() self._auto_reconnect = auto_reconnect self._heartbeat_interval = heartbeat_interval - self._conn = AsyncTdxConnection(host, port, timeout) + self._conn = AsyncTdxConnection(self._host, self._port, self._timeout) self._execute_lock = asyncio.Lock() self._heartbeat_task: asyncio.Task[None] | None = None @classmethod def from_best_host( cls, - hosts: list[str] = KNOWN_HOSTS, - port: int = _DEFAULT_PORT, - timeout: float = 15.0, + hosts: list[str] | None = None, + port: int | None = None, + timeout: float | None = None, ping_timeout: float = 5.0, auto_reconnect: bool = True, heartbeat_interval: float = 60.0, ) -> "AsyncTdxClient": - """测量 hosts 中所有服务器延迟,选最低延迟的建立连接。""" + """测量 hosts 中所有服务器延迟,选最低延迟的建立连接。 + + 自动将最佳主机保存到 config.json。 + """ + if hosts is None: + hosts = get_known_hosts() + if port is None: + port = get_port() + if timeout is None: + timeout = get_timeout() ranked = ping_all(hosts, port, ping_timeout) best = ranked[0][0] if ranked else hosts[0] + save_best_host(best) return cls(best, port, timeout, auto_reconnect, heartbeat_interval) @staticmethod def ping_all( - hosts: list[str] = KNOWN_HOSTS, - port: int = _DEFAULT_PORT, + hosts: list[str] | None = None, + port: int | None = None, timeout: float = 5.0, ) -> list[tuple[str, float]]: """测量多台服务器延迟,返回按延迟排序的 (host, seconds) 列表。""" + if hosts is None: + hosts = get_known_hosts() + if port is None: + port = get_port() return ping_all(hosts, port, timeout) async def connect(self) -> None: @@ -805,17 +869,24 @@ class AsyncTdxClient: pass async def _execute(self, cmd: "BaseCommand[_T]") -> _T: - """执行命令;断线时尝试重连一次再重试(若 auto_reconnect=True)。""" + """执行命令;断线时指数退避重试。""" 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 = AsyncTdxConnection(self._host, self._port, self._timeout) - await self._conn.connect() - return await self._conn.execute(cmd) + last_exc: TdxConnectionError | None = None + for delay in _RETRY_DELAYS: + await asyncio.sleep(delay) + await self._conn.close() + self._conn = AsyncTdxConnection(self._host, self._port, self._timeout) + await self._conn.connect() + try: + return await self._conn.execute(cmd) + except TdxConnectionError as e: + last_exc = e + raise last_exc # type: ignore[misc] async def get_security_count(self, market: Market) -> int: return await self._execute(GetSecurityCountCmd(market)) @@ -1025,18 +1096,24 @@ class AsyncTdxClient: finally: await conn.close() - async def get_financial_file_list(self, host: str = CALC_HOSTS[0]) -> pd.DataFrame: + async def get_financial_file_list(self, host: str | None = None) -> pd.DataFrame: """获取可用的历史专业财报文件列表(异步)。""" + if host is None: + host = get_calc_hosts()[0] data = await self._async_download_from_host(host, "tdxfin/gpcw.txt") raw_list = parse_financial_file_list(data) return _to_df([FinancialFileInfo(filename=f, hash=h, filesize=s) for f, h, s in raw_list]) - async def get_financial_file(self, filename: str, host: str = CALC_HOSTS[0]) -> bytes: + async def get_financial_file(self, filename: str, host: str | None = None) -> bytes: """从计算服务器下载财报 zip 文件(异步)。""" + if host is None: + host = get_calc_hosts()[0] return await self._async_download_from_host(host, filename) - async def get_financial_records(self, filename: str, host: str = CALC_HOSTS[0]) -> pd.DataFrame: + async def get_financial_records(self, filename: str, host: str | None = None) -> pd.DataFrame: """下载财报 zip 并解析为记录列表(异步)。""" + if host is None: + host = get_calc_hosts()[0] import io import re import zipfile @@ -1057,7 +1134,7 @@ class AsyncTdxClient: raw_records = parse_financial_dat(dat_data, report_date) records: list[FinancialRecord] = [] for code, market_byte, rdate, fields in raw_records: - market = Market.SH if market_byte == b"\x01" else Market.SZ + market = Market.SH if market_byte == 1 else Market.SZ records.append( FinancialRecord(code=code, market=market, report_date=rdate, fields=fields) ) diff --git a/src/easy_tdx/codec/bitmap.py b/src/easy_tdx/codec/bitmap.py new file mode 100644 index 0000000..f73b859 --- /dev/null +++ b/src/easy_tdx/codec/bitmap.py @@ -0,0 +1,489 @@ +"""MAC 协议字段位图编解码。 + +提供 FieldBit 定义、预定义字段集合(PresetField)、字段选择器(FieldSelection), +以及 20 字节请求位图的构建与响应位图解析。 +""" + +from collections.abc import Iterable, Iterator +from enum import Enum, IntEnum +from typing import TypeAlias + +# ── 统一的字段选择类型 ── +Fields: TypeAlias = "FieldBit | PresetField | FieldSelection | Iterable[FieldBit]" + + +class FieldBit(IntEnum): + """字段位定义,自带格式和描述,单一数据源。""" + + fmt: str # 由 __new__ 设置 + desc: str # 由 __new__ 设置 + + def __new__(cls, value: int, fmt: str = " "FieldBit": + obj = int.__new__(cls, value) + obj._value_ = value + obj.fmt = fmt + obj.desc = desc + return obj + + @property + def field_name(self) -> str: + """返回英文字段名,用于 DataFrame 列名等。""" + return self.name.lower() + + # ── 基础字段 (0x00-0x05) ── + PRE_CLOSE = 0x00, " str: + """A/H股代码补齐位数。""" + if not value: + return "" + # 沪深北 5 位,其他 6 位 + width = 5 if market in (0, 1) else 6 + return str(value).zfill(width) + + +FIELD_POSTPROCESS: dict[int, object] = { + 0x4A: _post_ah_code, # AH_CODE: 补齐0 +} + + +# ── 控制区(位128-159, 4字节) ── +# 前16字节(位0-127)是字段位图, 后4字节(位128-159)是控制区: +# 字节16(位128-135): 盘口深度(bid3_price~bid4_volume) +# 字节17(位136-143): 排除/限流位 +# 字节18(位144-151): 日内涨幅(change_at_1000~1430) +# 字节19(位152-159): 控制字节(CTRL_EXTENDED等) + +CTRL_BYTE = 0 # 控制字节起始位(152) +CTRL_EXTENDED = 1 # 非0=扩展模式(含北交所等),0=标准模式(仅A股) + + +# ── 预定义字段集合 ── +class PresetField(Enum): + """预定义字段集合,支持 + / | 链式组合。 + + Usage: + PresetField.BASIC + PresetField.VOLUME # 两个预设合并 + PresetField.OHLC + FieldBit.AH_CODE # 预设 + 单字段 + FieldBit.OPEN + FieldBit.HIGH + FieldBit.LOW # 纯字段组合 + """ + + NONE = () + OHLC = (FieldBit.OPEN, FieldBit.HIGH, FieldBit.LOW, FieldBit.CLOSE) + BASIC = ( + FieldBit.OPEN, + FieldBit.HIGH, + FieldBit.LOW, + FieldBit.CLOSE, + FieldBit.PRE_CLOSE, + FieldBit.VOL, + ) + QUOTE = ( + FieldBit.BID_PRICE, + FieldBit.ASK_PRICE, + FieldBit.BID_VOLUME, + FieldBit.ASK_VOLUME, + FieldBit.LAST_VOLUME, + ) + VOLUME = (FieldBit.VOL, FieldBit.AMOUNT, FieldBit.TURNOVER, FieldBit.VOL_RATIO) + FUNDAMENTAL = ( + FieldBit.TOTAL_SHARES, + FieldBit.FLOAT_SHARES, + FieldBit.EPS, + FieldBit.NET_ASSETS, + ) + ENHANCED = ( + FieldBit.OPEN, + FieldBit.HIGH, + FieldBit.LOW, + FieldBit.CLOSE, + FieldBit.VOL, + FieldBit.FLOAT_SHARES, + FieldBit.ACTIVITY, + ) + AH_CODE_FIELDS = ( + FieldBit.OPEN, + FieldBit.HIGH, + FieldBit.LOW, + FieldBit.CLOSE, + FieldBit.VOL, + FieldBit.AH_CODE, + FieldBit.LOT_SIZE, + FieldBit.INDUSTRY, + ) + BOARD_STATS = ( + FieldBit.LIMIT_UP_COUNT, + FieldBit.LIMIT_DOWN_COUNT, + FieldBit.UP_COUNT, + FieldBit.DOWN_COUNT, + ) + HANDICAP = ( + FieldBit.BID_PRICE, + FieldBit.BID2_PRICE, + FieldBit.BID3_PRICE, + FieldBit.BID4_PRICE, + FieldBit.BID5_PRICE, + FieldBit.ASK_PRICE, + FieldBit.ASK2_PRICE, + FieldBit.ASK3_PRICE, + FieldBit.ASK4_PRICE, + FieldBit.ASK5_PRICE, + FieldBit.BID_VOLUME, + FieldBit.BID2_VOLUME, + FieldBit.BID3_VOLUME, + FieldBit.BID4_VOLUME, + FieldBit.BID5_VOLUME, + FieldBit.ASK_VOLUME, + FieldBit.ASK2_VOLUME, + FieldBit.ASK3_VOLUME, + FieldBit.ASK4_VOLUME, + FieldBit.ASK5_VOLUME, + ) + COMMON = ( + FieldBit.PRE_CLOSE, + FieldBit.OPEN, + FieldBit.HIGH, + FieldBit.LOW, + FieldBit.CLOSE, + FieldBit.VOL, + FieldBit.VOL_RATIO, + FieldBit.AMOUNT, + FieldBit.TOTAL_SHARES, + FieldBit.FLOAT_SHARES, + FieldBit.EPS, + FieldBit.NET_ASSETS, + FieldBit.SECURITY_TYPE_PRICE, + FieldBit.TOTAL_MARKET_CAP_AB, + FieldBit.PE_DYNAMIC, + FieldBit.LOT_SIZE_INFO, + FieldBit.DIVIDEND_YIELD, + FieldBit.LAST_VOLUME, + FieldBit.TURNOVER, + FieldBit.STOCK_TAG_FLAGS, + FieldBit.DECIMAL_POINT, + FieldBit.BUY_PRICE_LIMIT, + FieldBit.SELL_PRICE_LIMIT, + FieldBit.PRICE_DECIMAL_INFO, + FieldBit.LOT_SIZE, + FieldBit.PRE_IOPV, + FieldBit.SPEED_PCT, + FieldBit.FLAG_KCB, + FieldBit.PE_TTM, + FieldBit.PE_STATIC, + FieldBit.MAIN_NET_AMOUNT, + FieldBit.VOL_SPEED_PCT, + FieldBit.SHORT_TURNOVER_PCT, + FieldBit.CIRCULATING_CAPITAL_Z, + ) + DEBUG = (-1, "", "调试用全字段") + ALL = tuple(FieldBit) + + def __add__(self, other: object) -> "FieldSelection": + if isinstance(other, FieldBit | PresetField | FieldSelection): + return FieldSelection(self, other) + return NotImplemented + + def __or__(self, other: object) -> "FieldSelection": + return self.__add__(other) + + def __radd__(self, other: object) -> "FieldSelection": + if isinstance(other, FieldBit | FieldSelection): + return FieldSelection(other, self) + return NotImplemented + + def __ror__(self, other: object) -> "FieldSelection": + return self.__radd__(other) + + +class FieldSelection: + """字段选择器,支持 PresetField + FieldBit 组合。 + + Usage: + PresetField.BASIC + FieldBit.AH_CODE + PresetField.BASIC | FieldBit.INDUSTRY + FieldBit.OPEN + FieldBit.HIGH + FieldBit.LOW + """ + + __slots__ = ("_fields",) + + def __init__(self, *parts: "FieldBit | PresetField | FieldSelection") -> None: + seen: set[FieldBit] = set() + result: list[FieldBit] = [] + for part in parts: + if isinstance(part, PresetField): + source: Iterable[FieldBit] = part.value + elif isinstance(part, FieldBit): + source = (part,) + else: + source = part._fields + for bit in source: + if bit not in seen: + seen.add(bit) + result.append(bit) + self._fields: tuple[FieldBit, ...] = tuple(result) + + def __add__(self, other: object) -> "FieldSelection": + if isinstance(other, FieldBit | PresetField | FieldSelection): + return FieldSelection(self, other) + return NotImplemented + + def __or__(self, other: object) -> "FieldSelection": + return self.__add__(other) + + def __radd__(self, other: object) -> "FieldSelection": + if isinstance(other, FieldBit | PresetField): + return FieldSelection(other, self) + return NotImplemented + + def __ror__(self, other: object) -> "FieldSelection": + return self.__radd__(other) + + def __iter__(self) -> Iterator[FieldBit]: + return iter(self._fields) + + def __len__(self) -> int: + return len(self._fields) + + def __bool__(self) -> bool: + return bool(self._fields) + + def __contains__(self, item: object) -> bool: + return item in self._fields + + def __repr__(self) -> str: + names = [bit.name for bit in self._fields] + return f"FieldSelection([{', '.join(names)}])" + + +def normalize_fields(fields: "Fields") -> FieldSelection: + """将任意字段选择形式归一化为 FieldSelection。""" + if fields is None: + return FieldSelection() + if isinstance(fields, FieldSelection): + return fields + if isinstance(fields, PresetField): + return FieldSelection(*fields.value) + if isinstance(fields, FieldBit): + return FieldSelection(fields) + return FieldSelection(*fields) + + +def build_bitmap( + fields: "Fields", + exclude_flags: int = 0, +) -> bytearray: + """将字段选择转换为 20 字节请求位图。 + + Parameters + ---------- + fields : Fields + 字段选择,可以是 PresetField、FieldBit、FieldSelection 或可迭代对象。 + exclude_flags : int + 控制区 4 字节(位 128-159)的值,默认 0。 + + Returns + ------- + bytearray + 20 字节位图。 + """ + if isinstance(fields, PresetField) and fields is PresetField.DEBUG: + return bytearray(b"\xff" * 20) + selection = normalize_fields(fields) + bitmap_int = 0 + for bit in selection: + bitmap_int |= 1 << bit.value + ba = bytearray(bitmap_int.to_bytes(16, "little")) + ba.extend(exclude_flags.to_bytes(4, "little")) + return ba + + +def build_exclude_flags(exclude_flags: int = 0) -> bytes: + """构建 4 字节控制区。 + + Parameters + ---------- + exclude_flags : int + 控制区原始值,默认 0。 + + Returns + ------- + bytes + 4 字节控制区。 + """ + return exclude_flags.to_bytes(4, "little") + + +def get_active_fields(bitmap_bytes: bytes) -> list[tuple[FieldBit, str]]: + """从响应位图解析活跃字段。 + + Parameters + ---------- + bitmap_bytes : bytes + 响应中的位图字节(通常 16 或 20 字节)。 + + Returns + ------- + list[tuple[FieldBit, str]] + 活跃字段及其格式说明符,按位序升序。 + """ + bitmap_int = int.from_bytes(bitmap_bytes, "little") + active: list[tuple[FieldBit, str]] = [] + while bitmap_int: + lowbit = bitmap_int & -bitmap_int + bit_pos = lowbit.bit_length() - 1 + bitmap_int ^= lowbit + field = FieldBit._value2member_map_.get(bit_pos) + if field is not None and isinstance(field, FieldBit): + active.append((field, field.fmt)) + return active diff --git a/src/easy_tdx/codec/mac_frame.py b/src/easy_tdx/codec/mac_frame.py new file mode 100644 index 0000000..fac43ed --- /dev/null +++ b/src/easy_tdx/codec/mac_frame.py @@ -0,0 +1,50 @@ +"""MAC 协议请求帧构建。 + +MAC 协议请求帧格式(10 字节头 + body): + struct " bytes: + """构建 MAC 协议请求帧。 + + Parameters + ---------- + msg_id : int + MAC 命令 ID(如 0x122B)。 + body : bytes + 命令特有的请求体(不含 msg_id 前缀)。 + head_flag : int + 帧头标识字节,默认 0x1C(标准 MAC)。部分命令(如 0x1218) + 使用不同的 head_flag 区分子协议。 + + Returns + ------- + bytes + 完整的请求帧(10 字节头 + 2 字节 msg_id + body)。 + """ + inner = struct.pack(" ~/.easy_tdx/config.json > 源码内嵌默认值。 + +配置文件示例:: + + { + "best_host": "180.153.18.170", + "best_host_updated_at": "2026-05-22T10:30:00", + "known_hosts": ["111.229.247.189", ...], + "calc_hosts": ["120.76.152.87"], + "mac_hosts": ["121.36.248.138", ...], + "port": 7709, + "timeout": 15.0 + } + +环境变量覆盖:: + + EASY_TDX_HOST -- 单台主机地址 + EASY_TDX_PORT -- 端口 + EASY_TDX_TIMEOUT -- 超时秒数 + EASY_TDX_KNOWN_HOSTS -- 逗号分隔的候选主机列表 + EASY_TDX_CONFIG_DIR -- 配置文件目录(默认 ~/.easy_tdx) +""" + +import json +import os +from datetime import datetime +from pathlib import Path +from typing import Any + +_CONFIG_DIR = Path(os.environ.get("EASY_TDX_CONFIG_DIR", str(Path.home() / ".easy_tdx"))) +_CONFIG_FILE = _CONFIG_DIR / "config.json" + +# --------------------------------------------------------------------------- +# 源码内嵌默认值(config.json 不存在或字段缺失时的兜底) +# --------------------------------------------------------------------------- + +_FALLBACK_HOSTS: list[str] = [ + "111.229.247.189", + "150.158.160.2", + "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", + "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", + "123.60.164.122", + "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", +] + +_FALLBACK_CALC_HOSTS: list[str] = [ + "120.76.152.87", +] + +_FALLBACK_MAC_HOSTS: list[str] = [ + "121.36.248.138", + "123.60.47.136", + "121.37.207.165", +] + +_FALLBACK_EX_HOSTS: list[str] = [ + "112.74.214.43", + "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", +] + +_FALLBACK_MAC_EX_HOSTS: list[str] = [ + "116.205.135.205", + "121.37.232.167", +] + +_FALLBACK_PORT = 7709 +_FALLBACK_TIMEOUT = 15.0 + + +# --------------------------------------------------------------------------- +# 内部读写 +# --------------------------------------------------------------------------- + + +def _load() -> dict[str, Any]: + try: + if _CONFIG_FILE.exists(): + return json.loads(_CONFIG_FILE.read_text("utf-8")) + except Exception: + pass + return {} + + +def _save(data: dict[str, Any]) -> None: + _CONFIG_DIR.mkdir(parents=True, exist_ok=True) + tmp = _CONFIG_FILE.with_suffix(".tmp") + tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), "utf-8") + tmp.replace(_CONFIG_FILE) + + +# --------------------------------------------------------------------------- +# 公开 getter +# --------------------------------------------------------------------------- + + +def get_best_host() -> str: + """返回当前最佳主机地址。优先级:环境变量 > config.json > 默认列表首个。""" + env = os.environ.get("EASY_TDX_HOST") + if env: + return env + cfg = _load() + return cfg.get("best_host", _FALLBACK_HOSTS[0]) + + +def get_known_hosts() -> list[str]: + """返回候选行情主机列表。""" + env = os.environ.get("EASY_TDX_KNOWN_HOSTS") + if env: + return [h.strip() for h in env.split(",") if h.strip()] + cfg = _load() + return cfg.get("known_hosts", list(_FALLBACK_HOSTS)) + + +def get_calc_hosts() -> list[str]: + """返回计算服务器列表。""" + cfg = _load() + return cfg.get("calc_hosts", list(_FALLBACK_CALC_HOSTS)) + + +def get_mac_hosts() -> list[str]: + """返回 MAC 行情服务器列表。""" + cfg = _load() + return cfg.get("mac_hosts", list(_FALLBACK_MAC_HOSTS)) + + +def get_ex_hosts() -> list[str]: + """返回扩展行情服务器列表。""" + cfg = _load() + return cfg.get("ex_hosts", list(_FALLBACK_EX_HOSTS)) + + +def get_best_ex_host() -> str: + """返回当前最佳扩展行情主机。""" + env = os.environ.get("EASY_TDX_EX_HOST") + if env: + return env + cfg = _load() + return cfg.get("best_ex_host", _FALLBACK_EX_HOSTS[0]) + + +def get_mac_ex_hosts() -> list[str]: + """返回 MAC 协议扩展行情服务器列表。""" + cfg = _load() + return cfg.get("mac_ex_hosts", list(_FALLBACK_MAC_EX_HOSTS)) + + +def get_best_mac_ex_host() -> str: + """返回当前最佳 MAC 协议扩展行情主机。""" + env = os.environ.get("EASY_TDX_MAC_EX_HOST") + if env: + return env + cfg = _load() + return cfg.get("best_mac_ex_host", _FALLBACK_MAC_EX_HOSTS[0]) + + +def get_port() -> int: + """返回默认端口。""" + env = os.environ.get("EASY_TDX_PORT") + if env: + return int(env) + cfg = _load() + return cfg.get("port", _FALLBACK_PORT) + + +def get_timeout() -> float: + """返回默认超时秒数。""" + env = os.environ.get("EASY_TDX_TIMEOUT") + if env: + return float(env) + cfg = _load() + return cfg.get("timeout", _FALLBACK_TIMEOUT) + + +# --------------------------------------------------------------------------- +# 持久化 +# --------------------------------------------------------------------------- + + +def save_best_host(host: str) -> None: + """保存最佳主机到配置文件;首次写入时同时补全默认配置。""" + cfg = _load() + cfg["best_host"] = host + cfg["best_host_updated_at"] = datetime.now().isoformat() + if "known_hosts" not in cfg: + cfg["known_hosts"] = list(_FALLBACK_HOSTS) + if "calc_hosts" not in cfg: + cfg["calc_hosts"] = list(_FALLBACK_CALC_HOSTS) + if "mac_hosts" not in cfg: + cfg["mac_hosts"] = list(_FALLBACK_MAC_HOSTS) + if "port" not in cfg: + cfg["port"] = _FALLBACK_PORT + if "ex_hosts" not in cfg: + cfg["ex_hosts"] = list(_FALLBACK_EX_HOSTS) + if "mac_ex_hosts" not in cfg: + cfg["mac_ex_hosts"] = list(_FALLBACK_MAC_EX_HOSTS) + _save(cfg) + + +def save_best_ex_host(host: str) -> None: + """保存最佳扩展行情主机到配置文件。""" + cfg = _load() + cfg["best_ex_host"] = host + cfg["best_ex_host_updated_at"] = datetime.now().isoformat() + if "ex_hosts" not in cfg: + cfg["ex_hosts"] = list(_FALLBACK_EX_HOSTS) + if "mac_ex_hosts" not in cfg: + cfg["mac_ex_hosts"] = list(_FALLBACK_MAC_EX_HOSTS) + _save(cfg) + + +def save_best_mac_ex_host(host: str) -> None: + """保存最佳 MAC 协议扩展行情主机到配置文件。""" + cfg = _load() + cfg["best_mac_ex_host"] = host + cfg["best_mac_ex_host_updated_at"] = datetime.now().isoformat() + if "mac_ex_hosts" not in cfg: + cfg["mac_ex_hosts"] = list(_FALLBACK_MAC_EX_HOSTS) + _save(cfg) diff --git a/src/easy_tdx/ex/__init__.py b/src/easy_tdx/ex/__init__.py index aa1440d..9733763 100644 --- a/src/easy_tdx/ex/__init__.py +++ b/src/easy_tdx/ex/__init__.py @@ -1,11 +1,15 @@ """easy_tdx.ex — 通达信扩展行情(期货、港股、外股等,端口 7727)。""" from .client import AsyncExTdxClient, ExTdxClient -from .models import KNOWN_EX_HOSTS, KNOWN_EX_MARKETS +from .mac_client import AsyncMacExClient, MacExClient +from .models import KNOWN_EX_HOSTS, KNOWN_EX_MARKETS, MAC_EX_HOSTS __all__ = [ "ExTdxClient", "AsyncExTdxClient", + "MacExClient", + "AsyncMacExClient", "KNOWN_EX_HOSTS", "KNOWN_EX_MARKETS", + "MAC_EX_HOSTS", ] diff --git a/src/easy_tdx/ex/client.py b/src/easy_tdx/ex/client.py index 05b02e8..1802408 100644 --- a/src/easy_tdx/ex/client.py +++ b/src/easy_tdx/ex/client.py @@ -6,6 +6,7 @@ from types import TracebackType from typing import TypeVar from ..commands.base import BaseCommand +from ..config import get_best_ex_host, get_ex_hosts, save_best_ex_host from ..exceptions import TdxConnectionError from .commands.get_history_bars_range import GetExHistoryInstrumentBarsRangeCmd from .commands.get_instrument_bars import GetExInstrumentBarsCmd @@ -23,7 +24,6 @@ from .commands.get_transaction import ( GetExTransactionDataCmd, ) from .models import ( - KNOWN_EX_HOSTS, ExInstrumentBar, ExInstrumentInfo, ExInstrumentQuote, @@ -55,16 +55,16 @@ class ExTdxClient: def __init__( self, - host: str = KNOWN_EX_HOSTS[0], + host: str | None = None, port: int = _DEFAULT_EX_PORT, timeout: float = 15.0, auto_reconnect: bool = True, ) -> None: - self._host = host + self._host = host if host is not None else get_best_ex_host() self._port = port self._timeout = timeout self._auto_reconnect = auto_reconnect - self._conn = ExTdxConnection(host, port, timeout) + self._conn = ExTdxConnection(self._host, port, timeout) @classmethod def from_best_host( @@ -75,9 +75,12 @@ class ExTdxClient: ping_timeout: float = 5.0, auto_reconnect: bool = True, ) -> "ExTdxClient": - """测量所有扩展行情服务器延迟,选最低延迟建立连接。""" + """测量所有扩展行情服务器延迟,选最低延迟建立连接。自动保存最佳主机。""" + if hosts is None: + hosts = get_ex_hosts() ranked = ping_ex_all(hosts, port, ping_timeout) - best = ranked[0][0] if ranked else (hosts or KNOWN_EX_HOSTS)[0] + best = ranked[0][0] if ranked else hosts[0] + save_best_ex_host(best) return cls(best, port, timeout, auto_reconnect) @staticmethod @@ -239,18 +242,18 @@ class AsyncExTdxClient: def __init__( self, - host: str = KNOWN_EX_HOSTS[0], + host: str | None = None, port: int = _DEFAULT_EX_PORT, timeout: float = 15.0, auto_reconnect: bool = True, heartbeat_interval: float = 60.0, ) -> None: - self._host = host + self._host = host if host is not None else get_best_ex_host() self._port = port self._timeout = timeout self._auto_reconnect = auto_reconnect self._heartbeat_interval = heartbeat_interval - self._conn = AsyncExTdxConnection(host, port, timeout) + self._conn = AsyncExTdxConnection(self._host, port, timeout) self._execute_lock = asyncio.Lock() self._heartbeat_task: asyncio.Task[None] | None = None @@ -264,8 +267,11 @@ class AsyncExTdxClient: auto_reconnect: bool = True, heartbeat_interval: float = 60.0, ) -> "AsyncExTdxClient": + if hosts is None: + hosts = get_ex_hosts() ranked = ping_ex_all(hosts, port, ping_timeout) - best = ranked[0][0] if ranked else (hosts or KNOWN_EX_HOSTS)[0] + best = ranked[0][0] if ranked else hosts[0] + save_best_ex_host(best) return cls(best, port, timeout, auto_reconnect, heartbeat_interval) @staticmethod diff --git a/src/easy_tdx/ex/commands/get_instrument_count.py b/src/easy_tdx/ex/commands/get_instrument_count.py index d744487..0da7a23 100644 --- a/src/easy_tdx/ex/commands/get_instrument_count.py +++ b/src/easy_tdx/ex/commands/get_instrument_count.py @@ -14,4 +14,4 @@ class GetExInstrumentCountCmd(BaseCommand[int]): if len(body) < 23: return 0 (count,) = unpack_from(" bytes: + inner = struct.pack(" bool: + # Login 响应 body 非空即视为成功 + return len(body) >= 2 diff --git a/src/easy_tdx/ex/mac_client.py b/src/easy_tdx/ex/mac_client.py new file mode 100644 index 0000000..d41e66f --- /dev/null +++ b/src/easy_tdx/ex/mac_client.py @@ -0,0 +1,715 @@ +"""MAC 协议扩展市场高层 API:MacExClient(同步)和 AsyncMacExClient(asyncio)。 + +期货/港股/美股等扩展市场通过 MAC 协议命令(0x122B/0x122E/0x122D/0x122F/0x2562) +获取数据,使用 ExTdxConnection(端口 7727,单包握手)。 +""" + +import asyncio +from datetime import date +from types import TracebackType +from typing import Any, TypeVar + +import pandas as pd + +from .._df import _to_df +from ..commands.base import BaseCommand +from ..exceptions import TdxConnectionError +from .commands.login import MacExLoginCmd +from .commands.get_instrument_count import GetExInstrumentCountCmd +from .commands.get_instrument_info import GetExInstrumentInfoCmd +from ..mac.commands.chart_sampling import ChartSamplingCmd +from ..mac.commands.symbol_bar import SymbolBarCmd +from ..mac.commands.symbol_quotes import SymbolQuotesCmd +from ..mac.commands.symbol_tick_chart import SymbolTickChartCmd +from ..mac.commands.symbol_transaction import SymbolTransactionCmd +from ..mac.enums import Adjust, Period, SortOrder, SortType +from ..config import get_best_mac_ex_host, get_mac_ex_hosts, save_best_mac_ex_host +from ..mac.models import MacQuoteField +from .transport.async_ import AsyncExTdxConnection +from .transport.sync import ExTdxConnection, ping_ex_all + +_DEFAULT_PORT = 7727 +_T = TypeVar("_T") + + +def _quotes_to_df(result: list[MacQuoteField]) -> pd.DataFrame: + """将 MacQuoteField 列表展开为 DataFrame。""" + rows: list[dict[str, Any]] = [] + for item in result: + row: dict[str, Any] = {"market": item.market, "code": item.code, "name": item.name} + row.update(item.fields) + rows.append(row) + return pd.DataFrame(rows) if rows else pd.DataFrame() + + +# ============================================================ +# 同步客户端 +# ============================================================ + + +class MacExClient: + """同步 MAC 协议扩展市场客户端(期货/港股/美股,端口 7727)。 + + 使用示例:: + + with MacExClient() as c: + df = c.goods_kline(ExMarket.CFFEX_FUTURES, "IFL0", Period.DAILY) + df = c.goods_quotes([(ExMarket.HK_MAIN_BOARD, "00700")]) + """ + + def __init__( + self, + host: str | None = None, + port: int = _DEFAULT_PORT, + timeout: float = 15.0, + auto_reconnect: bool = True, + ) -> None: + self._host = host if host is not None else get_best_mac_ex_host() + self._port = port + self._timeout = timeout + self._auto_reconnect = auto_reconnect + self._conn = ExTdxConnection(self._host, port, timeout, mac_ex_mode=True) + + @classmethod + def from_best_host( + cls, + hosts: list[str] | None = None, + port: int = _DEFAULT_PORT, + timeout: float = 15.0, + ping_timeout: float = 5.0, + auto_reconnect: bool = True, + ) -> "MacExClient": + """测量所有 MAC 扩展行情服务器延迟,选最低延迟建立连接。""" + candidates = hosts or get_mac_ex_hosts() + ranked = ping_ex_all(candidates, port, ping_timeout) + best = ranked[0][0] if ranked else candidates[0] + save_best_mac_ex_host(best) + return cls(best, port, timeout, auto_reconnect) + + @staticmethod + def ping_all( + hosts: list[str] | None = None, + port: int = _DEFAULT_PORT, + timeout: float = 5.0, + ) -> list[tuple[str, float]]: + return ping_ex_all(hosts or get_mac_ex_hosts(), port, timeout) + + # ------------------------------------------------------------------ # + # 连接管理 + # ------------------------------------------------------------------ # + + def connect(self) -> None: + self._conn.connect() + self._login() + + def close(self) -> None: + self._conn.close() + + def disconnect(self) -> None: + self.close() + + def ensure_connected(self) -> None: + """验证连接存活,断线则自动重建。""" + try: + self._execute(GetExInstrumentCountCmd()) + except TdxConnectionError: + self._conn.close() + self._conn = ExTdxConnection(self._host, self._port, self._timeout, mac_ex_mode=True) + self._conn.connect() + self._login() + + def __enter__(self) -> "MacExClient": + self.connect() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close() + + def _login(self) -> None: + """执行 MAC EX 登录命令。""" + self._conn.execute(MacExLoginCmd()) + + 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, mac_ex_mode=True) + self._conn.connect() + self._login() + return self._conn.execute(cmd) + + # ------------------------------------------------------------------ # + # 商品列表 + # ------------------------------------------------------------------ # + + def goods_count(self, market: int | None = None) -> int: + """获取商品总数。market=None 时返回全市场总数,否则返回指定市场的数量。""" + if market is None: + return self._execute(GetExInstrumentCountCmd()) + # 需要二分查找定位市场边界来计数 + offset = self._find_market_offset(market) + if offset < 0: + return 0 + total = self._execute(GetExInstrumentCountCmd()) + # 从 offset 开始扫描计数 + n = 0 + page = 1000 + pos = offset + while pos < total: + batch = self._execute(GetExInstrumentInfoCmd(start=pos, count=page)) + if not batch: + break + for item in batch: + if item.market == market: + n += 1 + elif item.market > market: + return n + pos += page + return n + + def goods_list(self, market: int, start: int = 0, count: int = 600) -> pd.DataFrame: + """获取扩展市场商品列表(期货合约/港股/美股等)。 + + 通过 EX 协议的 GetInstrumentInfo 命令获取,按 market 过滤。 + + Parameters + ---------- + market : int + ExMarket 枚举值,如 ExMarket.HK_MAIN_BOARD。 + start : int + 市场内起始偏移。 + count : int + 请求数量。 + """ + offset = self._find_market_offset(market) + if offset < 0: + return pd.DataFrame() + total = self._execute(GetExInstrumentCountCmd()) + page_size = 1000 + collected: list = [] + skipped = 0 + pos = offset + while pos < total and len(collected) < count: + batch = self._execute(GetExInstrumentInfoCmd(start=pos, count=page_size)) + if not batch: + break + for item in batch: + if item.market == market: + if skipped < start: + skipped += 1 + else: + collected.append(item) + if len(collected) >= count: + break + elif item.market > market: + break + else: + pos += page_size + continue + break + return _to_df(collected) + + def _find_market_offset(self, market: int) -> int: + """二分查找定位指定市场在全局商品列表中的起始偏移。""" + total = self._execute(GetExInstrumentCountCmd()) + if total == 0: + return -1 + lo, hi = 0, total + while lo < hi: + mid = (lo + hi) // 2 + items = self._execute(GetExInstrumentInfoCmd(start=mid, count=1)) + if not items: + hi = mid + continue + m = items[0].market + if m < market: + lo = mid + 1 + else: + hi = mid + return lo + + # ------------------------------------------------------------------ # + # 行情 + # ------------------------------------------------------------------ # + + def goods_quotes( + self, + stocks: list[tuple[int, str]], + fields: Any = None, + ) -> pd.DataFrame: + """批量获取扩展市场自定义字段报价。 + + Parameters + ---------- + stocks : list[tuple[int, str]] + [(ExMarketcode, code), ...] 列表,最多 80 只。 + fields : Fields | None + 字段选择,默认 PresetField.COMMON。 + """ + cmd = SymbolQuotesCmd(stocks, fields) + result: list[MacQuoteField] = self._execute(cmd) + return _quotes_to_df(result) + + def goods_quotes_list( + self, + market: int, + start: int = 0, + count: int = 100, + sort_type: SortType = SortType.CODE, + sort_order: SortOrder = SortOrder.NONE, + ) -> pd.DataFrame: + """获取扩展市场排序报价列表(通过 GoodsList + Quotes 组合)。 + + 先获取商品列表,再批量查询报价。 + + Parameters + ---------- + market : int + ExMarket 枚举值。 + start : int + 起始偏移。 + count : int + 返回条数(最大 80,受报价批量限制)。 + sort_type : SortType + 排序字段(暂未实现排序,预留接口)。 + sort_order : SortOrder + 排序方向(暂未实现排序,预留接口)。 + """ + page_size = min(count, 80) + items_df = self.goods_list(market, start=start, count=page_size) + if items_df.empty: + return pd.DataFrame() + stocks: list[tuple[int, str]] = [] + for _, row in items_df.iterrows(): + stocks.append((market, row["code"])) + cmd = SymbolQuotesCmd(stocks) + result: list[MacQuoteField] = self._execute(cmd) + return _quotes_to_df(result) + + def goods_kline( + self, + market: int, + code: str, + period: Period = Period.DAILY, + start: int = 0, + count: int = 800, + adjust: Adjust = Adjust.NONE, + ) -> pd.DataFrame: + """获取扩展市场 K 线数据(支持复权)。 + + Parameters + ---------- + market : int + ExMarket 枚举值。 + code : str + 证券代码。 + period : Period + K 线周期。 + start : int + 起始偏移(0=最新)。 + count : int + 返回条数。 + adjust : Adjust + 复权方式(NONE/QFQ/HFQ)。 + """ + cmd = SymbolBarCmd( + market=market, + code=code, + period=period, + start=start, + count=count, + fq=adjust, + ) + result = self._execute(cmd) + return _to_df(result) + + # ------------------------------------------------------------------ # + # 分时 + # ------------------------------------------------------------------ # + + def goods_tick_chart( + self, + market: int, + code: str, + query_date: date | None = None, + ) -> pd.DataFrame: + """获取单日分时图。 + + Parameters + ---------- + market : int + ExMarket 枚举值。 + code : str + 证券代码。 + query_date : date | None + 查询日期,None 表示今天。 + """ + cmd = SymbolTickChartCmd(market=market, code=code, query_date=query_date) + result = self._execute(cmd) + return _to_df(result) + + def goods_chart_sampling( + self, + market: int, + code: str, + ) -> pd.DataFrame: + """获取分时缩略采样价格点(约 240 个点)。 + + Parameters + ---------- + market : int + ExMarket 枚举值。 + code : str + 证券代码。 + """ + cmd = ChartSamplingCmd(market=market, code=code) + prices: list[float] = self._execute(cmd) + if not prices: + return pd.DataFrame() + return pd.DataFrame({"price": prices}) + + # ------------------------------------------------------------------ # + # 成交 + # ------------------------------------------------------------------ # + + def goods_transaction( + self, + market: int, + code: str, + query_date: date | None = None, + start: int = 0, + count: int = 2000, + ) -> pd.DataFrame: + """获取逐笔成交数据。 + + Parameters + ---------- + market : int + ExMarket 枚举值。 + code : str + 证券代码。 + query_date : date | None + 查询日期,None 表示今天。 + start : int + 起始偏移。 + count : int + 返回条数。 + """ + cmd = SymbolTransactionCmd( + market=market, + code=code, + query_date=query_date, + start=start, + count=count, + ) + result = self._execute(cmd) + return _to_df(result) + + +# ============================================================ +# 异步客户端 +# ============================================================ + + +class AsyncMacExClient: + """异步 MAC 协议扩展市场客户端(asyncio,端口 7727)。 + + 使用示例:: + + async with AsyncMacExClient() as c: + df = await c.goods_kline(ExMarket.CFFEX_FUTURES, "IFL0", Period.DAILY) + """ + + def __init__( + self, + host: str | None = None, + port: int = _DEFAULT_PORT, + timeout: float = 15.0, + auto_reconnect: bool = True, + heartbeat_interval: float = 60.0, + ) -> None: + self._host = host if host is not None else get_best_mac_ex_host() + self._port = port + self._timeout = timeout + self._auto_reconnect = auto_reconnect + self._heartbeat_interval = heartbeat_interval + self._conn = AsyncExTdxConnection(self._host, port, timeout, mac_ex_mode=True) + 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_PORT, + timeout: float = 15.0, + ping_timeout: float = 5.0, + auto_reconnect: bool = True, + heartbeat_interval: float = 60.0, + ) -> "AsyncMacExClient": + candidates = hosts or get_mac_ex_hosts() + ranked = ping_ex_all(candidates, port, ping_timeout) + best = ranked[0][0] if ranked else candidates[0] + save_best_mac_ex_host(best) + return cls(best, port, timeout, auto_reconnect, heartbeat_interval) + + @staticmethod + def ping_all( + hosts: list[str] | None = None, + port: int = _DEFAULT_PORT, + timeout: float = 5.0, + ) -> list[tuple[str, float]]: + return ping_ex_all(hosts or get_mac_ex_hosts(), port, timeout) + + # ------------------------------------------------------------------ # + # 连接管理 + # ------------------------------------------------------------------ # + + async def connect(self) -> None: + await self._conn.connect() + await self._login() + self._start_heartbeat() + + async def close(self) -> None: + await self._stop_heartbeat() + await self._conn.close() + + async def __aenter__(self) -> "AsyncMacExClient": + 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._execute(GetExInstrumentCountCmd()) + except asyncio.CancelledError: + break + except Exception: + pass + + async def _login(self) -> None: + """执行 MAC EX 登录命令。""" + await self._conn.execute(MacExLoginCmd()) + + 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, mac_ex_mode=True) + await self._conn.connect() + await self._login() + return await self._conn.execute(cmd) + + # ------------------------------------------------------------------ # + # 商品列表 + # ------------------------------------------------------------------ # + + async def goods_count(self, market: int | None = None) -> int: + """获取商品总数。market=None 时返回全市场总数,否则返回指定市场的数量。""" + if market is None: + return await self._execute(GetExInstrumentCountCmd()) + offset = await self._find_market_offset(market) + if offset < 0: + return 0 + total = await self._execute(GetExInstrumentCountCmd()) + n = 0 + page = 1000 + pos = offset + while pos < total: + batch = await self._execute(GetExInstrumentInfoCmd(start=pos, count=page)) + if not batch: + break + for item in batch: + if item.market == market: + n += 1 + elif item.market > market: + return n + pos += page + return n + + async def goods_list(self, market: int, start: int = 0, count: int = 600) -> pd.DataFrame: + """获取扩展市场商品列表(期货合约/港股/美股等)。""" + offset = await self._find_market_offset(market) + if offset < 0: + return pd.DataFrame() + total = await self._execute(GetExInstrumentCountCmd()) + page_size = 1000 + collected: list = [] + skipped = 0 + pos = offset + while pos < total and len(collected) < count: + batch = await self._execute(GetExInstrumentInfoCmd(start=pos, count=page_size)) + if not batch: + break + for item in batch: + if item.market == market: + if skipped < start: + skipped += 1 + else: + collected.append(item) + if len(collected) >= count: + break + elif item.market > market: + break + else: + pos += page_size + continue + break + return _to_df(collected) + + async def _find_market_offset(self, market: int) -> int: + """二分查找定位指定市场在全局商品列表中的起始偏移。""" + total = await self._execute(GetExInstrumentCountCmd()) + if total == 0: + return -1 + lo, hi = 0, total + while lo < hi: + mid = (lo + hi) // 2 + items = await self._execute(GetExInstrumentInfoCmd(start=mid, count=1)) + if not items: + hi = mid + continue + m = items[0].market + if m < market: + lo = mid + 1 + else: + hi = mid + return lo + + # ------------------------------------------------------------------ # + # 行情 + # ------------------------------------------------------------------ # + + async def goods_quotes( + self, + stocks: list[tuple[int, str]], + fields: Any = None, + ) -> pd.DataFrame: + cmd = SymbolQuotesCmd(stocks, fields) + result: list[MacQuoteField] = await self._execute(cmd) + return _quotes_to_df(result) + + async def goods_quotes_list( + self, + market: int, + start: int = 0, + count: int = 100, + sort_type: SortType = SortType.CODE, + sort_order: SortOrder = SortOrder.NONE, + ) -> pd.DataFrame: + page_size = min(count, 80) + items_df = await self.goods_list(market, start=start, count=page_size) + if items_df.empty: + return pd.DataFrame() + stocks: list[tuple[int, str]] = [(market, row["code"]) for _, row in items_df.iterrows()] + cmd = SymbolQuotesCmd(stocks) + result: list[MacQuoteField] = await self._execute(cmd) + return _quotes_to_df(result) + + # ------------------------------------------------------------------ # + # K 线 + # ------------------------------------------------------------------ # + + async def goods_kline( + self, + market: int, + code: str, + period: Period = Period.DAILY, + start: int = 0, + count: int = 800, + adjust: Adjust = Adjust.NONE, + ) -> pd.DataFrame: + cmd = SymbolBarCmd( + market=market, + code=code, + period=period, + start=start, + count=count, + fq=adjust, + ) + result = await self._execute(cmd) + return _to_df(result) + + # ------------------------------------------------------------------ # + # 分时 + # ------------------------------------------------------------------ # + + async def goods_tick_chart( + self, + market: int, + code: str, + query_date: date | None = None, + ) -> pd.DataFrame: + cmd = SymbolTickChartCmd(market=market, code=code, query_date=query_date) + result = await self._execute(cmd) + return _to_df(result) + + async def goods_chart_sampling( + self, + market: int, + code: str, + ) -> pd.DataFrame: + cmd = ChartSamplingCmd(market=market, code=code) + prices: list[float] = await self._execute(cmd) + if not prices: + return pd.DataFrame() + return pd.DataFrame({"price": prices}) + + # ------------------------------------------------------------------ # + # 成交 + # ------------------------------------------------------------------ # + + async def goods_transaction( + self, + market: int, + code: str, + query_date: date | None = None, + start: int = 0, + count: int = 2000, + ) -> pd.DataFrame: + cmd = SymbolTransactionCmd( + market=market, + code=code, + query_date=query_date, + start=start, + count=count, + ) + result = await self._execute(cmd) + return _to_df(result) diff --git a/src/easy_tdx/ex/models.py b/src/easy_tdx/ex/models.py index f50021e..adf734a 100644 --- a/src/easy_tdx/ex/models.py +++ b/src/easy_tdx/ex/models.py @@ -2,34 +2,10 @@ 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", -] +from ..config import get_ex_hosts, get_mac_ex_hosts + +# 模块级别名,供外部 `from easy_tdx.ex.models import KNOWN_EX_HOSTS` 使用。 +KNOWN_EX_HOSTS = get_ex_hosts() # 已知扩展行情市场代码 KNOWN_EX_MARKETS: dict[int, str] = { @@ -46,6 +22,9 @@ KNOWN_EX_MARKETS: dict[int, str] = { 74: "外盘", } +# MAC 协议扩展行情服务器(端口 7727) +MAC_EX_HOSTS: list[str] = get_mac_ex_hosts() + _DEFAULT_EX_PORT = 7727 diff --git a/src/easy_tdx/ex/transport/async_.py b/src/easy_tdx/ex/transport/async_.py index 56142ca..263731c 100644 --- a/src/easy_tdx/ex/transport/async_.py +++ b/src/easy_tdx/ex/transport/async_.py @@ -5,8 +5,8 @@ from types import TracebackType from typing import TYPE_CHECKING, TypeVar from ...codec.frame import HEADER_SIZE, decompress_body, parse_header +from ...config import get_best_ex_host, get_ex_hosts from ...exceptions import TdxConnectionError -from ..commands.setup import EX_SETUP_CMD from ..models import KNOWN_EX_HOSTS if TYPE_CHECKING: @@ -19,17 +19,27 @@ _DEFAULT_TIMEOUT = 15.0 class AsyncExTdxConnection: - """扩展行情异步 TCP 连接(asyncio,端口 7727,单包握手)。""" + """扩展行情异步 TCP 连接(asyncio,端口 7727,单包握手)。 + + Parameters + ---------- + mac_ex_mode : bool + 为 True 时自动将 MAC 命令的 head_flag 从 0x1C 转为 0x01, + 以兼容 MAC EX 服务器(需要 head_flag=0x01)。 + """ def __init__( self, - host: str = KNOWN_EX_HOSTS[0], + host: str | None = None, port: int = _DEFAULT_EX_PORT, timeout: float = _DEFAULT_TIMEOUT, + *, + mac_ex_mode: bool = False, ) -> None: - self.host = host + self.host = host if host is not None else get_best_ex_host() self.port = port self.timeout = timeout + self.mac_ex_mode = mac_ex_mode self._reader: asyncio.StreamReader | None = None self._writer: asyncio.StreamWriter | None = None self._io_lock = asyncio.Lock() @@ -49,6 +59,8 @@ class AsyncExTdxConnection: if self._writer is None or self._reader is None: raise TdxConnectionError("未连接,请先调用 connect()") request = cmd.build_request() + if self.mac_ex_mode and len(request) > 0 and request[0] == 0x1C: + request = b"\x01" + request[1:] try: self._writer.write(request) await asyncio.wait_for(self._writer.drain(), timeout=self.timeout) @@ -75,11 +87,6 @@ class AsyncExTdxConnection: 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: @@ -103,20 +110,6 @@ class AsyncExTdxConnection: ) -> 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( diff --git a/src/easy_tdx/ex/transport/sync.py b/src/easy_tdx/ex/transport/sync.py index 0bada44..ae128f8 100644 --- a/src/easy_tdx/ex/transport/sync.py +++ b/src/easy_tdx/ex/transport/sync.py @@ -1,13 +1,15 @@ """扩展行情同步 TCP 连接(端口 7727)。""" import socket +import threading import time from types import TracebackType from typing import TYPE_CHECKING, TypeVar from ...codec.frame import HEADER_SIZE, decompress_body, parse_header +from ...config import get_best_ex_host, get_ex_hosts from ...exceptions import TdxConnectionError -from ..commands.setup import EX_SETUP_CMD +from ..commands.get_instrument_count import GetExInstrumentCountCmd from ..models import KNOWN_EX_HOSTS if TYPE_CHECKING: @@ -24,13 +26,14 @@ def ping_ex_host( port: int = _DEFAULT_EX_PORT, timeout: float = 5.0, ) -> float | None: - """测量扩展行情服务器延迟(秒)。失败返回 None。""" + """测量扩展行情服务器延迟(秒)。通过发送 get_instrument_count 验证可用性。""" 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) + cmd = GetExInstrumentCountCmd() + sock.sendall(cmd.build_request()) hdr_buf = _recv_exact_sock(sock, HEADER_SIZE) hdr = parse_header(hdr_buf) if hdr.zipsize > 0: @@ -54,7 +57,7 @@ def ping_ex_all( import concurrent.futures if hosts is None: - hosts = KNOWN_EX_HOSTS + hosts = get_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} @@ -78,21 +81,32 @@ def _recv_exact_sock(sock: socket.socket, n: int) -> bytes: class ExTdxConnection: - """扩展行情同步 TCP 连接(端口 7727,单包握手)。""" + """扩展行情同步 TCP 连接(端口 7727,单包握手)。 + + Parameters + ---------- + mac_ex_mode : bool + 为 True 时自动将 MAC 命令的 head_flag 从 0x1C 转为 0x01, + 以兼容 MAC EX 服务器(需要 head_flag=0x01)。 + """ def __init__( self, - host: str = KNOWN_EX_HOSTS[0], + host: str | None = None, port: int = _DEFAULT_EX_PORT, timeout: float = _DEFAULT_TIMEOUT, + *, + mac_ex_mode: bool = False, ) -> None: - self.host = host + self.host = host if host is not None else get_best_ex_host() self.port = port self.timeout = timeout + self.mac_ex_mode = mac_ex_mode self._sock: socket.socket | None = None + self._lock = threading.Lock() def connect(self) -> None: - """建立 TCP 连接并完成扩展行情握手。""" + """建立 TCP 连接。扩展行情服务器不需要握手命令。""" sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(self.timeout) try: @@ -101,15 +115,6 @@ class ExTdxConnection: 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: @@ -121,18 +126,21 @@ class ExTdxConnection: 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) + with self._lock: + if self._sock is None: + raise TdxConnectionError("未连接,请先调用 connect()") + request = cmd.build_request() + if self.mac_ex_mode and len(request) > 0 and request[0] == 0x1C: + request = b"\x01" + request[1:] + 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() @@ -146,18 +154,6 @@ class ExTdxConnection: ) -> 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) diff --git a/src/easy_tdx/mac/__init__.py b/src/easy_tdx/mac/__init__.py new file mode 100644 index 0000000..8b8a68c --- /dev/null +++ b/src/easy_tdx/mac/__init__.py @@ -0,0 +1 @@ +"""MAC 协议客户端(板块、竞价、复权K线等高级接口)。""" diff --git a/src/easy_tdx/mac/client.py b/src/easy_tdx/mac/client.py new file mode 100644 index 0000000..1097cfa --- /dev/null +++ b/src/easy_tdx/mac/client.py @@ -0,0 +1,1257 @@ +"""MAC 协议高层 API:MacClient(同步)和 AsyncMacClient(asyncio)。""" + +from __future__ import annotations + +import asyncio +import time +from dataclasses import asdict +from types import TracebackType +from typing import Any, TypeVar + +import pandas as pd + +from .._df import _to_df +from ..commands.base import BaseCommand +from ..config import get_best_host, get_mac_hosts, get_port, get_timeout, save_best_host +from ..exceptions import TdxConnectionError +from ..transport.async_ import AsyncTdxConnection +from ..transport.sync import TdxConnection, ping_mac_all +from .commands import ( + BoardListCmd, + BoardMembersQuotesCmd, + KlineOffsetCmd, + ServerInfoCmd, + SymbolAuctionCmd, + SymbolBarCmd, + SymbolBelongBoardCmd, + SymbolCapitalFlowCmd, + SymbolInfoCmd, + SymbolQuotesCmd, + SymbolTickChartCmd, + SymbolTransactionCmd, + TickChartsCmd, + UnusualCmd, +) +from .commands.chart_sampling import ChartSamplingCmd +from .commands.file_query import FileDownloadCmd, FileListCmd +from .commands.goods_list import GoodsListCmd +from ..codec.bitmap import Fields, PresetField +from .enums import Adjust, BoardType, Category, FilterType, Period, SortOrder, SortType +from .models import ( + MacBar, + MacMultiTickChart, + MacQuoteField, + MacTickChart, +) + +_RETRY_DELAYS = (0.1, 0.5, 1.0, 2.0) +_KLINE_PAGE_SIZE = 700 +_BOARD_MEMBERS_PAGE_SIZE = 80 + + +def _convert_board_code(board_symbol: str) -> int: + """将用户可见的板块代码转换为服务器协议代码。 + + 转换规则(来自 opentdx exchange_board_code): + US0401 → 30401 (30000 + N) + HK0283 → 20283 (20000 + N) + 000686 → 31686 (31000 + N) + 399372 → 30372 (N - 399000 + 30000) + 899050 → 32050 (N - 899000 + 32000) + 880686 → 20686 (N - 880000 + 20000) + 其他 → int(N) + """ + s = board_symbol.strip() + if s.startswith("US"): + return 30000 + int(s[2:]) + if s.startswith("HK"): + return 20000 + int(s[2:]) + if len(s) == 6: + if s.startswith("88"): + return int(s) - 880000 + 20000 + if s.startswith("399"): + return int(s) - 399000 + 30000 + if s.startswith("899"): + return int(s) - 899000 + 32000 + if s.startswith("000"): + return 31000 + int(s) + return int(s) +_TRANSACTION_PAGE_SIZE = 1000 + +_T = TypeVar("_T") + + +def _flatten_quote_fields(quotes: list[MacQuoteField]) -> list[dict[str, Any]]: + """将 MacQuoteField 展平为 DataFrame 友好的 dict 列表。""" + rows: list[dict[str, Any]] = [] + for q in quotes: + d: dict[str, Any] = {"market": q.market, "code": q.code, "name": q.name} + d.update(q.fields) + rows.append(d) + return rows + + +def _quotes_to_df(quotes: list[MacQuoteField]) -> pd.DataFrame: + return pd.DataFrame(_flatten_quote_fields(quotes)) + + +def _flatten_tick_chart(chart: MacTickChart) -> list[dict[str, Any]]: + """将 MacTickChart 的 ticks 展平为 DataFrame 行。""" + rows: list[dict[str, Any]] = [] + for tick in chart.charts: + rows.append(asdict(tick)) + return rows + + +def _flatten_multi_tick_chart(chart: MacMultiTickChart) -> list[dict[str, Any]]: + """将 MacMultiTickChart 的所有天的 ticks 展平为 DataFrame 行。""" + rows: list[dict[str, Any]] = [] + for day in chart.charts: + for tick in day.ticks: + d = asdict(tick) + d["date"] = day.date + d["pre_close"] = day.pre_close + rows.append(d) + return rows + + +# ============================================================ +# 同步客户端 +# ============================================================ + + +class MacClient: + """同步 MAC 协议客户端,支持 IP 优选与断线自动重连。 + + 使用示例:: + + with MacClient("121.36.248.138") as c: + df = c.get_stock_kline(0, "600000", Period.DAILY, count=100) + + # 自动选延迟最低的 MAC 服务器 + with MacClient.from_best_host() as c: + df = c.get_board_list() + """ + + def __init__( + self, + host: str | None = None, + port: int | None = None, + timeout: float | None = None, + auto_reconnect: bool = True, + heartbeat_interval: float = 15.0, + ) -> None: + self._host = host if host is not None else get_best_host() + self._port = port if port is not None else get_port() + self._timeout = timeout if timeout is not None else get_timeout() + self._auto_reconnect = auto_reconnect + self._heartbeat_interval = heartbeat_interval + self._conn = TdxConnection(self._host, self._port, self._timeout) + + # ------------------------------------------------------------------ # + # 工厂方法 + # ------------------------------------------------------------------ # + + @classmethod + def from_best_host( + cls, + hosts: list[str] | None = None, + port: int | None = None, + timeout: float | None = None, + ping_timeout: float = 5.0, + auto_reconnect: bool = True, + heartbeat_interval: float = 15.0, + ) -> MacClient: + """测量所有 MAC 服务器延迟,选最低延迟的建立客户端。自动保存最佳主机。""" + if hosts is None: + hosts = get_mac_hosts() + if port is None: + port = get_port() + if timeout is None: + timeout = get_timeout() + ranked = ping_mac_all(hosts, port, ping_timeout) + best = ranked[0][0] if ranked else hosts[0] + save_best_host(best) + return cls(best, port, timeout, auto_reconnect, heartbeat_interval) + + @staticmethod + def ping_all( + hosts: list[str] | None = None, + port: int | None = None, + timeout: float = 5.0, + ) -> list[tuple[str, float]]: + """测量多台 MAC 服务器延迟,返回按延迟排序的 (host, seconds) 列表。""" + if hosts is None: + hosts = get_mac_hosts() + if port is None: + port = get_port() + return ping_mac_all(hosts, port, timeout) + + # ------------------------------------------------------------------ # + # 连接管理 + # ------------------------------------------------------------------ # + + def connect(self) -> None: + self._conn.connect() + if self._heartbeat_interval > 0: + self._conn.start_heartbeat(self._heartbeat_interval) + + def close(self) -> None: + self._conn.stop_heartbeat() + self._conn.close() + + def disconnect(self) -> None: + """Alias for close().""" + self.close() + + def ensure_connected(self) -> None: + """验证连接存活,断线则自动重建。""" + try: + self._execute(KlineOffsetCmd(0, 1)) + except TdxConnectionError: + self._conn.stop_heartbeat() + self._conn.close() + self._conn = TdxConnection(self._host, self._port, self._timeout) + self._conn.connect() + if self._heartbeat_interval > 0: + self._conn.start_heartbeat(self._heartbeat_interval) + + def __enter__(self) -> MacClient: + 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 + last_exc: TdxConnectionError | None = None + for delay in _RETRY_DELAYS: + time.sleep(delay) + self._conn.close() + self._conn = TdxConnection(self._host, self._port, self._timeout) + self._conn.connect() + if self._heartbeat_interval > 0: + self._conn.start_heartbeat(self._heartbeat_interval) + try: + return self._conn.execute(cmd) + except TdxConnectionError as e: + last_exc = e + raise last_exc # type: ignore[misc] + + # ------------------------------------------------------------------ # + # 报价 + # ------------------------------------------------------------------ # + + def get_stock_quotes( + self, + stocks: list[tuple[int, str]], + fields: object = None, + ) -> pd.DataFrame: + """批量获取自定义字段报价(最多80只/次)。 + + Args: + stocks: [(market, code), ...] 列表。 + fields: 字段选择,默认 PresetField.COMMON。 + """ + quotes = self._execute(SymbolQuotesCmd(stocks, fields)) # type: ignore[arg-type] + return _quotes_to_df(quotes) + + def get_stock_quotes_list( + self, + category: Category, + start: int = 0, + count: int = 80, + sort_type: SortType = SortType.CHANGE_PCT, + sort_order: SortOrder = SortOrder.DESC, + exclude_flags: list[FilterType] | None = None, + fields: Fields | None = None, + ) -> pd.DataFrame: + """获取市场分类报价列表(自动分页)。 + + Args: + category: 市场分类(如 Category.A, Category.SH, Category.KCB 等)。 + start: 起始偏移。 + count: 请求总数。 + sort_type: 排序字段。 + sort_order: 排序方向。 + exclude_flags: 过滤标志列表。 + fields: 请求字段集合,默认 PresetField.BASIC + PresetField.VOLUME。 + """ + if fields is None: + fields = PresetField.BASIC + PresetField.VOLUME + all_quotes: list[MacQuoteField] = [] + fetched = 0 + page_size = min(count, _BOARD_MEMBERS_PAGE_SIZE) + offset = start + + while fetched < count: + batch = self._execute( + BoardMembersQuotesCmd( + board_code=int(category), + sort_type=sort_type, + start=offset, + page_size=page_size, + sort_order=sort_order, + fields=fields, + exclude_flags=exclude_flags, + ) + ) + if not batch: + break + all_quotes = batch + all_quotes + fetched += len(batch) + offset += len(batch) + if len(batch) < page_size: + break + + return _quotes_to_df(all_quotes) + + # ------------------------------------------------------------------ # + # K 线(支持复权) + # ------------------------------------------------------------------ # + + def get_stock_kline( + self, + market: int, + code: str, + period: Period = Period.DAILY, + start: int = 0, + count: int = 800, + times: int = 1, + adjust: Adjust = Adjust.NONE, + ) -> pd.DataFrame: + """获取 K 线数据(自动分页,每页最多 700 条)。 + + Args: + market: 市场代码。 + code: 股票代码。 + period: K 线周期。 + start: 起始偏移(0 = 最新)。 + count: 总请求条数。 + times: 周期倍数(Period.MINS/DAYS 时有效)。 + adjust: 复权方式。 + """ + all_bars: list[MacBar] = [] + fetched = 0 + offset = start + + while fetched < count: + page_size = min(count - fetched, _KLINE_PAGE_SIZE) + bars = self._execute( + SymbolBarCmd( + market=market, + code=code, + period=period, + times=times, + start=offset, + count=page_size, + fq=adjust, + ) + ) + if not bars: + break + all_bars = bars + all_bars + fetched += len(bars) + offset += len(bars) + if len(bars) < page_size: + break + + return _to_df(all_bars) + + # ------------------------------------------------------------------ # + # 分时 + # ------------------------------------------------------------------ # + + def get_tick_chart( + self, + market: int, + code: str, + date: int | None = None, + ) -> pd.DataFrame: + """获取单日分时图。 + + Args: + market: 市场代码。 + code: 股票代码。 + date: 查询日期(YYYYMMDD),None 表示今天。 + """ + from datetime import date as date_cls + + query_date = ( + date_cls(date // 10000, (date % 10000) // 100, date % 100) + if date is not None else None + ) + chart = self._execute(SymbolTickChartCmd(market, code, query_date)) + return pd.DataFrame(_flatten_tick_chart(chart)) + + def get_tick_charts( + self, + market: int, + code: str, + date: int | None = None, + days: int = 5, + ) -> pd.DataFrame: + """获取多日分时图(最多 5 天)。 + + Args: + market: 市场代码。 + code: 股票代码。 + date: 起始日期(YYYYMMDD),None 表示从最新交易日开始。 + days: 天数。 + """ + from datetime import date as date_cls + + start_date = ( + date_cls(date // 10000, (date % 10000) // 100, date % 100) + if date is not None else None + ) + chart = self._execute(TickChartsCmd(market, code, start_date, days)) + return pd.DataFrame(_flatten_multi_tick_chart(chart)) + + def get_chart_sampling(self, market: int, code: str) -> pd.DataFrame: + """获取分时缩略采样价格点(240 个点)。 + + Args: + market: 市场代码。 + code: 股票代码。 + """ + prices = self._execute(ChartSamplingCmd(market, code)) + return pd.DataFrame({"price": prices}) + + # ------------------------------------------------------------------ # + # 逐笔成交 + # ------------------------------------------------------------------ # + + def get_transactions( + self, + market: int, + code: str, + count: int = 2000, + start: int = 0, + date: int | None = None, + ) -> pd.DataFrame: + """获取逐笔成交数据(自动分页)。 + + Args: + market: 市场代码。 + code: 股票代码。 + count: 请求总数。 + start: 起始偏移。 + date: 查询日期(YYYYMMDD),None 表示今天。 + """ + from datetime import date as date_cls + + query_date = ( + date_cls(date // 10000, (date % 10000) // 100, date % 100) + if date is not None else None + ) + all_items = self._execute( + SymbolTransactionCmd( + market, code, query_date, start, min(count, _TRANSACTION_PAGE_SIZE) + ) + ) + fetched = len(all_items) + offset = start + fetched + + while fetched < count: + page_size = min(count - fetched, _TRANSACTION_PAGE_SIZE) + batch = self._execute(SymbolTransactionCmd(market, code, query_date, offset, page_size)) + if not batch: + break + all_items.extend(batch) + fetched += len(batch) + offset += len(batch) + if len(batch) < page_size: + break + + return _to_df(all_items) + + # ------------------------------------------------------------------ # + # 个股信息 + # ------------------------------------------------------------------ # + + def get_symbol_info(self, market: int, code: str) -> pd.DataFrame: + """获取个股简要特征快照。 + + Args: + market: 市场代码。 + code: 股票代码。 + """ + info = self._execute(SymbolInfoCmd(market, code)) + return _to_df(info) + + # ------------------------------------------------------------------ # + # 板块 + # ------------------------------------------------------------------ # + + def get_board_list( + self, + board_type: BoardType = BoardType.ALL, + count: int = 10000, + ) -> pd.DataFrame: + """获取板块列表(自动分页)。 + + Args: + board_type: 板块类型。 + count: 请求总数。 + """ + all_items = self._execute(BoardListCmd(board_type, 0, min(count, 150))) + fetched = len(all_items) + offset = fetched + + while fetched < count: + page_size = min(count - fetched, 150) + batch = self._execute(BoardListCmd(board_type, offset, page_size)) + if not batch: + break + all_items.extend(batch) + fetched += len(batch) + offset += len(batch) + if len(batch) < page_size: + break + + return _to_df(all_items) + + def get_board_members( + self, + board_symbol: str, + count: int = 100000, + sort_type: SortType = SortType.CHANGE_PCT, + sort_order: SortOrder = SortOrder.DESC, + fields: object = PresetField.COMMON, + exclude_flags: list[FilterType] | None = None, + ) -> pd.DataFrame: + """获取板块成分股报价(自动分页)。 + + Args: + board_symbol: 板块代码(如 "881001")。 + count: 请求总数。 + sort_type: 排序字段。 + sort_order: 排序方向。 + fields: 字段选择。 + exclude_flags: 过滤标志列表。 + """ + board_code = _convert_board_code(board_symbol) + all_quotes: list[MacQuoteField] = [] + fetched = 0 + offset = 0 + + while fetched < count: + page_size = min(count - fetched, _BOARD_MEMBERS_PAGE_SIZE) + batch = self._execute( + BoardMembersQuotesCmd( + board_code=board_code, + sort_type=sort_type, + start=offset, + page_size=page_size, + sort_order=sort_order, + fields=fields, # type: ignore[arg-type] + exclude_flags=exclude_flags, + ) + ) + if not batch: + break + all_quotes = batch + all_quotes + fetched += len(batch) + offset += len(batch) + if len(batch) < page_size: + break + + return _quotes_to_df(all_quotes) + + def get_belong_board(self, market: int, code: str) -> pd.DataFrame: + """获取个股所属板块列表。 + + Args: + market: 市场代码。 + code: 股票代码。 + """ + items = self._execute(SymbolBelongBoardCmd(market, code)) + return _to_df(items) + + # ------------------------------------------------------------------ # + # 资金流向 + # ------------------------------------------------------------------ # + + def get_capital_flow(self, market: int, code: str) -> pd.DataFrame: + """获取个股资金流向。 + + Args: + market: 市场代码。 + code: 股票代码。 + """ + data = self._execute(SymbolCapitalFlowCmd(market, code)) + if data is None: + return pd.DataFrame() + return _to_df(data) + + # ------------------------------------------------------------------ # + # 集合竞价 + # ------------------------------------------------------------------ # + + def get_auction(self, market: int, code: str) -> pd.DataFrame: + """获取集合竞价数据。 + + Args: + market: 市场代码。 + code: 股票代码。 + """ + items = self._execute(SymbolAuctionCmd(market, code)) + return _to_df(items) + + # ------------------------------------------------------------------ # + # 异动 + # ------------------------------------------------------------------ # + + def get_unusual( + self, + market: int, + start: int = 0, + count: int = 0, + ) -> pd.DataFrame: + """获取市场异动数据。 + + Args: + market: 市场代码。 + start: 起始偏移。 + count: 请求数量(0 表示使用默认值 600)。 + """ + items = self._execute(UnusualCmd(market, start, count or 600)) + return _to_df(items) + + # ------------------------------------------------------------------ # + # 服务器信息 + # ------------------------------------------------------------------ # + + def get_server_info(self) -> pd.DataFrame: + """获取服务器交易时段信息。""" + info = self._execute(ServerInfoCmd()) + return _to_df(info) + + def get_kline_offset( + self, + offset: int = 0, + count: int = 128000, + ) -> pd.DataFrame: + """获取 K 线数据偏移信息。 + + Args: + offset: 偏移量。 + count: 请求数量。 + """ + info = self._execute(KlineOffsetCmd(offset, count)) + return _to_df(info) + + # ------------------------------------------------------------------ # + # 文件操作 + # ------------------------------------------------------------------ # + + def get_file_meta(self, filename: str) -> pd.DataFrame: + """查询远程文件元信息。 + + Args: + filename: 远程文件名。 + """ + meta = self._execute(FileListCmd(filename)) + return _to_df(meta) + + def download_file_chunk( + self, + filename: str, + index: int, + offset: int, + size: int, + ) -> bytes: + """下载远程文件的一个分片。 + + Args: + filename: 远程文件名。 + index: 分段序号(1-based)。 + offset: 字节偏移。 + size: 请求块大小。 + """ + return self._execute(FileDownloadCmd(filename, index, offset, size)) + + def download_file( + self, + filename: str, + filesize: int = 0, + ) -> bytearray: + """下载完整远程文件。 + + Args: + filename: 远程文件名。 + filesize: 预期文件大小(0 表示自动检测)。 + """ + if filesize <= 0: + meta = self._execute(FileListCmd(filename)) + filesize = meta.size + + full_data = bytearray() + chunk_size = 30000 + pos = 0 + idx = 1 + + while pos < filesize: + chunk = self._execute(FileDownloadCmd(filename, idx, pos, chunk_size)) + if not chunk: + break + full_data.extend(chunk) + pos += len(chunk) + idx += 1 + if len(chunk) < chunk_size: + break + + return full_data + + # ------------------------------------------------------------------ # + # 扩展市场 + # ------------------------------------------------------------------ # + + def get_goods_list( + self, + market: int, + start: int = 0, + count: int = 600, + ) -> pd.DataFrame: + """获取扩展市场(期货/期权等)商品列表。 + + Args: + market: 扩展市场代码(ExMarket 枚举值)。 + start: 起始偏移。 + count: 请求数量(最大 1000)。 + """ + items = self._execute(GoodsListCmd(market, start, count)) + return _to_df(items) + + +# ============================================================ +# 异步客户端 +# ============================================================ + + +class AsyncMacClient: + """异步 MAC 协议客户端(asyncio)。 + + 使用示例:: + + async with AsyncMacClient("121.36.248.138") as c: + df = await c.get_stock_kline(0, "600000", Period.DAILY, count=100) + + 注意: + 单个 AsyncMacClient 仅维护一条 TCP 连接;并发调用会在连接内串行执行。 + """ + + def __init__( + self, + host: str | None = None, + port: int | None = None, + timeout: float | None = None, + auto_reconnect: bool = True, + heartbeat_interval: float = 15.0, + ) -> None: + self._host = host if host is not None else get_best_host() + self._port = port if port is not None else get_port() + self._timeout = timeout if timeout is not None else get_timeout() + self._auto_reconnect = auto_reconnect + self._heartbeat_interval = heartbeat_interval + self._conn = AsyncTdxConnection(self._host, self._port, self._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 | None = None, + timeout: float | None = None, + ping_timeout: float = 5.0, + auto_reconnect: bool = True, + heartbeat_interval: float = 15.0, + ) -> AsyncMacClient: + """测量所有 MAC 服务器延迟,选最低延迟的建立客户端。自动保存最佳主机。""" + if hosts is None: + hosts = get_mac_hosts() + if port is None: + port = get_port() + if timeout is None: + timeout = get_timeout() + ranked = ping_mac_all(hosts, port, ping_timeout) + best = ranked[0][0] if ranked else hosts[0] + save_best_host(best) + return cls(best, port, timeout, auto_reconnect, heartbeat_interval) + + @staticmethod + def ping_all( + hosts: list[str] | None = None, + port: int | None = None, + timeout: float = 5.0, + ) -> list[tuple[str, float]]: + """测量多台 MAC 服务器延迟。""" + if hosts is None: + hosts = get_mac_hosts() + if port is None: + port = get_port() + return ping_mac_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 disconnect(self) -> None: + """Alias for close().""" + await self.close() + + async def ensure_connected(self) -> None: + """验证连接存活,断线则自动重建。""" + try: + await self._execute(KlineOffsetCmd(0, 1)) + except TdxConnectionError: + await self._stop_heartbeat() + await self._conn.close() + self._conn = AsyncTdxConnection(self._host, self._port, self._timeout) + await self._conn.connect() + self._start_heartbeat() + + async def __aenter__(self) -> AsyncMacClient: + 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._execute(KlineOffsetCmd(0, 1)) + 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 + last_exc: TdxConnectionError | None = None + for delay in _RETRY_DELAYS: + await asyncio.sleep(delay) + await self._conn.close() + self._conn = AsyncTdxConnection(self._host, self._port, self._timeout) + await self._conn.connect() + try: + return await self._conn.execute(cmd) + except TdxConnectionError as e: + last_exc = e + raise last_exc # type: ignore[misc] + + # ------------------------------------------------------------------ # + # 报价 + # ------------------------------------------------------------------ # + + async def get_stock_quotes( + self, + stocks: list[tuple[int, str]], + fields: object = None, + ) -> pd.DataFrame: + quotes = await self._execute(SymbolQuotesCmd(stocks, fields)) # type: ignore[arg-type] + return _quotes_to_df(quotes) + + async def get_stock_quotes_list( + self, + category: Category, + start: int = 0, + count: int = 80, + sort_type: SortType = SortType.CHANGE_PCT, + sort_order: SortOrder = SortOrder.DESC, + exclude_flags: list[FilterType] | None = None, + fields: Fields | None = None, + ) -> pd.DataFrame: + if fields is None: + fields = PresetField.BASIC + PresetField.VOLUME + all_quotes: list[MacQuoteField] = [] + fetched = 0 + page_size = min(count, _BOARD_MEMBERS_PAGE_SIZE) + offset = start + + while fetched < count: + batch = await self._execute( + BoardMembersQuotesCmd( + board_code=int(category), + sort_type=sort_type, + start=offset, + page_size=page_size, + sort_order=sort_order, + fields=fields, + exclude_flags=exclude_flags, + ) + ) + if not batch: + break + all_quotes = batch + all_quotes + fetched += len(batch) + offset += len(batch) + if len(batch) < page_size: + break + + return _quotes_to_df(all_quotes) + + # ------------------------------------------------------------------ # + # K 线 + # ------------------------------------------------------------------ # + + async def get_stock_kline( + self, + market: int, + code: str, + period: Period = Period.DAILY, + start: int = 0, + count: int = 800, + times: int = 1, + adjust: Adjust = Adjust.NONE, + ) -> pd.DataFrame: + all_bars: list[MacBar] = [] + fetched = 0 + offset = start + + while fetched < count: + page_size = min(count - fetched, _KLINE_PAGE_SIZE) + bars = await self._execute( + SymbolBarCmd( + market=market, + code=code, + period=period, + times=times, + start=offset, + count=page_size, + fq=adjust, + ) + ) + if not bars: + break + all_bars = bars + all_bars + fetched += len(bars) + offset += len(bars) + if len(bars) < page_size: + break + + return _to_df(all_bars) + + # ------------------------------------------------------------------ # + # 分时 + # ------------------------------------------------------------------ # + + async def get_tick_chart( + self, + market: int, + code: str, + date: int | None = None, + ) -> pd.DataFrame: + from datetime import date as date_cls + + query_date = ( + date_cls(date // 10000, (date % 10000) // 100, date % 100) + if date is not None else None + ) + chart = await self._execute(SymbolTickChartCmd(market, code, query_date)) + return pd.DataFrame(_flatten_tick_chart(chart)) + + async def get_tick_charts( + self, + market: int, + code: str, + date: int | None = None, + days: int = 5, + ) -> pd.DataFrame: + from datetime import date as date_cls + + start_date = ( + date_cls(date // 10000, (date % 10000) // 100, date % 100) + if date is not None else None + ) + chart = await self._execute(TickChartsCmd(market, code, start_date, days)) + return pd.DataFrame(_flatten_multi_tick_chart(chart)) + + async def get_chart_sampling(self, market: int, code: str) -> pd.DataFrame: + prices = await self._execute(ChartSamplingCmd(market, code)) + return pd.DataFrame({"price": prices}) + + # ------------------------------------------------------------------ # + # 逐笔成交 + # ------------------------------------------------------------------ # + + async def get_transactions( + self, + market: int, + code: str, + count: int = 2000, + start: int = 0, + date: int | None = None, + ) -> pd.DataFrame: + from datetime import date as date_cls + + query_date = ( + date_cls(date // 10000, (date % 10000) // 100, date % 100) + if date is not None else None + ) + all_items = await self._execute( + SymbolTransactionCmd( + market, code, query_date, start, min(count, _TRANSACTION_PAGE_SIZE) + ) + ) + fetched = len(all_items) + offset = start + fetched + + while fetched < count: + page_size = min(count - fetched, _TRANSACTION_PAGE_SIZE) + batch = await self._execute( + SymbolTransactionCmd(market, code, query_date, offset, page_size) + ) + if not batch: + break + all_items.extend(batch) + fetched += len(batch) + offset += len(batch) + if len(batch) < page_size: + break + + return _to_df(all_items) + + # ------------------------------------------------------------------ # + # 个股信息 + # ------------------------------------------------------------------ # + + async def get_symbol_info(self, market: int, code: str) -> pd.DataFrame: + info = await self._execute(SymbolInfoCmd(market, code)) + return _to_df(info) + + # ------------------------------------------------------------------ # + # 板块 + # ------------------------------------------------------------------ # + + async def get_board_list( + self, + board_type: BoardType = BoardType.ALL, + count: int = 10000, + ) -> pd.DataFrame: + all_items = await self._execute(BoardListCmd(board_type, 0, min(count, 150))) + fetched = len(all_items) + offset = fetched + + while fetched < count: + page_size = min(count - fetched, 150) + batch = await self._execute(BoardListCmd(board_type, offset, page_size)) + if not batch: + break + all_items.extend(batch) + fetched += len(batch) + offset += len(batch) + if len(batch) < page_size: + break + + return _to_df(all_items) + + async def get_board_members( + self, + board_symbol: str, + count: int = 100000, + sort_type: SortType = SortType.CHANGE_PCT, + sort_order: SortOrder = SortOrder.DESC, + fields: object = PresetField.COMMON, + exclude_flags: list[FilterType] | None = None, + ) -> pd.DataFrame: + board_code = _convert_board_code(board_symbol) + all_quotes: list[MacQuoteField] = [] + fetched = 0 + offset = 0 + + while fetched < count: + page_size = min(count - fetched, _BOARD_MEMBERS_PAGE_SIZE) + batch = await self._execute( + BoardMembersQuotesCmd( + board_code=board_code, + sort_type=sort_type, + start=offset, + page_size=page_size, + sort_order=sort_order, + fields=fields, # type: ignore[arg-type] + exclude_flags=exclude_flags, + ) + ) + if not batch: + break + all_quotes = batch + all_quotes + fetched += len(batch) + offset += len(batch) + if len(batch) < page_size: + break + + return _quotes_to_df(all_quotes) + + async def get_belong_board(self, market: int, code: str) -> pd.DataFrame: + items = await self._execute(SymbolBelongBoardCmd(market, code)) + return _to_df(items) + + # ------------------------------------------------------------------ # + # 资金流向 + # ------------------------------------------------------------------ # + + async def get_capital_flow(self, market: int, code: str) -> pd.DataFrame: + data = await self._execute(SymbolCapitalFlowCmd(market, code)) + if data is None: + return pd.DataFrame() + return _to_df(data) + + # ------------------------------------------------------------------ # + # 集合竞价 + # ------------------------------------------------------------------ # + + async def get_auction(self, market: int, code: str) -> pd.DataFrame: + items = await self._execute(SymbolAuctionCmd(market, code)) + return _to_df(items) + + # ------------------------------------------------------------------ # + # 异动 + # ------------------------------------------------------------------ # + + async def get_unusual( + self, + market: int, + start: int = 0, + count: int = 0, + ) -> pd.DataFrame: + items = await self._execute(UnusualCmd(market, start, count or 600)) + return _to_df(items) + + # ------------------------------------------------------------------ # + # 服务器信息 + # ------------------------------------------------------------------ # + + async def get_server_info(self) -> pd.DataFrame: + info = await self._execute(ServerInfoCmd()) + return _to_df(info) + + async def get_kline_offset( + self, + offset: int = 0, + count: int = 128000, + ) -> pd.DataFrame: + info = await self._execute(KlineOffsetCmd(offset, count)) + return _to_df(info) + + # ------------------------------------------------------------------ # + # 文件操作 + # ------------------------------------------------------------------ # + + async def get_file_meta(self, filename: str) -> pd.DataFrame: + meta = await self._execute(FileListCmd(filename)) + return _to_df(meta) + + async def download_file_chunk( + self, + filename: str, + index: int, + offset: int, + size: int, + ) -> bytes: + return await self._execute(FileDownloadCmd(filename, index, offset, size)) + + async def download_file( + self, + filename: str, + filesize: int = 0, + ) -> bytearray: + if filesize <= 0: + meta = await self._execute(FileListCmd(filename)) + filesize = meta.size + + full_data = bytearray() + chunk_size = 30000 + pos = 0 + idx = 1 + + while pos < filesize: + chunk = await self._execute(FileDownloadCmd(filename, idx, pos, chunk_size)) + if not chunk: + break + full_data.extend(chunk) + pos += len(chunk) + idx += 1 + if len(chunk) < chunk_size: + break + + return full_data + + # ------------------------------------------------------------------ # + # 扩展市场 + # ------------------------------------------------------------------ # + + async def get_goods_list( + self, + market: int, + start: int = 0, + count: int = 600, + ) -> pd.DataFrame: + items = await self._execute(GoodsListCmd(market, start, count)) + return _to_df(items) diff --git a/src/easy_tdx/mac/commands/__init__.py b/src/easy_tdx/mac/commands/__init__.py new file mode 100644 index 0000000..b067bce --- /dev/null +++ b/src/easy_tdx/mac/commands/__init__.py @@ -0,0 +1,33 @@ +"""MAC 协议命令。""" + +from .board_list import BoardListCmd +from .board_members_quotes import BoardMembersQuotesCmd +from .kline_offset import KlineOffsetCmd +from .server_info import ServerInfoCmd +from .symbol_auction import SymbolAuctionCmd +from .symbol_bar import SymbolBarCmd +from .symbol_belong_board import SymbolBelongBoardCmd +from .symbol_capital_flow import SymbolCapitalFlowCmd +from .symbol_info import SymbolInfoCmd +from .symbol_quotes import SymbolQuotesCmd +from .symbol_tick_chart import SymbolTickChartCmd +from .symbol_transaction import SymbolTransactionCmd +from .tick_charts import TickChartsCmd +from .unusual import UnusualCmd + +__all__ = [ + "BoardListCmd", + "BoardMembersQuotesCmd", + "KlineOffsetCmd", + "ServerInfoCmd", + "SymbolAuctionCmd", + "SymbolBarCmd", + "SymbolBelongBoardCmd", + "SymbolCapitalFlowCmd", + "SymbolInfoCmd", + "SymbolQuotesCmd", + "SymbolTickChartCmd", + "SymbolTransactionCmd", + "TickChartsCmd", + "UnusualCmd", +] diff --git a/src/easy_tdx/mac/commands/board_list.py b/src/easy_tdx/mac/commands/board_list.py new file mode 100644 index 0000000..f73afac --- /dev/null +++ b/src/easy_tdx/mac/commands/board_list.py @@ -0,0 +1,96 @@ +"""板块列表查询(0x1231)。""" + +import struct + +from ..._binary import unpack_from +from ...codec.mac_frame import build_mac_request +from ...commands.base import BaseCommand +from ..enums import BoardType +from ..models import BoardInfo + +# 板板信息 + 领涨股信息,每组 160 字节 +# fmt: H(2) + 6s(6) + 16s(16) + 44s(44) + f(4) + f(4) + f(4) = 80 +# x2 for board + symbol = 160 +_RECORD_FMT = " None: + self._board_type = board_type + self._start = start + self._page_size = page_size + + def build_request(self) -> bytes: + # list[BoardInfo]: + count_all, total = unpack_from(" None: + self._board_code = board_code + self._sort_type = sort_type + self._start = start + self._page_size = page_size + self._sort_order = sort_order + self._fields = fields + self._exclude_flags = exclude_flags or [] + + def build_request(self) -> bytes: + # I:board_code, 9x padding, H:sort_type, I:start, H:page_size, B:sort_order, B:pad + body = struct.pack( + " list[MacQuoteField]: + # 响应位图(20 字节) + resp_bitmap = body[:20] + + total, row_count = unpack_from(" None: + self.market = market + self.code = code + + def build_request(self) -> bytes: + raw_code = self.code.encode("gbk") + padded = (raw_code + b"\x00" * _CODE_LEN)[:_CODE_LEN] + body = struct.pack(" list[float]: + if len(body) < _RESPONSE_HEADER_SIZE: + return [] + require_bytes(body, 0, _RESPONSE_HEADER_SIZE, "ChartSamplingCmd header") + (count,) = unpack_from(" None: + self.filename = filename + self.offset = offset + + def build_request(self) -> bytes: + raw_name = self.filename.encode("gbk") + padded = (raw_name + b"\x00" * _FILENAME_LEN)[:_FILENAME_LEN] + body = struct.pack(" FileMeta: + require_bytes(body, 0, 4 + 4 + 1 + 32, "FileListCmd") + offset, size, flag = unpack_from(" None: + self.filename = filename + self.index = index + self.offset = offset + self.size = size + + def build_request(self) -> bytes: + raw_name = self.filename.encode("gbk") + padded = (raw_name + b"\x00" * _FILENAME_LEN)[:_FILENAME_LEN] + body = ( + struct.pack(" bytes: + if len(body) < 8: + return b"" + return body[8:] diff --git a/src/easy_tdx/mac/commands/goods_list.py b/src/easy_tdx/mac/commands/goods_list.py new file mode 100644 index 0000000..12e3a35 --- /dev/null +++ b/src/easy_tdx/mac/commands/goods_list.py @@ -0,0 +1,77 @@ +"""扩展市场商品列表命令(0x2562)。""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass + +from ..._binary import require_bytes, unpack_from +from ...codec.mac_frame import build_mac_request +from ...commands.base import BaseCommand + +_MSG_ID = 0x2562 +_MAX_COUNT = 1000 +_RECORD_SIZE = 48 +_RECORD_FMT = " None: + if count > _MAX_COUNT: + raise ValueError(f"count 不能超过 {_MAX_COUNT},当前: {count}") + self.market = market + self.start = start + self.count = count + self.total: int = 0 + + def build_request(self) -> bytes: + body = struct.pack(" list[GoodsItem]: + require_bytes(body, 0, 2, "GoodsListCmd header") + (total,) = unpack_from(" None: + self._offset = offset + self._count = count + + def build_request(self) -> bytes: + # I:offset, I:count, 5 bytes padding + body = struct.pack(" KlineOffsetInfo: + if len(body) < 8: + return KlineOffsetInfo(total=0, returned=0) + + # total 字段为大端序! + total = struct.unpack(">I", body[:4])[0] + returned = struct.unpack(" bytes: + # 固定 68 字节请求体 + header = bytes.fromhex("04002d31") + body = header + b"\x00" * 8 + b"\x00\x27\x06\x0e" + b"\x00" * 52 + return build_mac_request(0x120F, body) + + def parse_response(self, body: bytes) -> ServerSession: + if len(body) < 87: + return ServerSession(today="", last_trading_day="") + + pos = 0 + _count = unpack_from(" tuple[str, int]: + d = unpack_from(" tuple[list[dict[str, object]], int]: + vals = unpack_from("<8H", body, p, "server_info session") + sessions: list[dict[str, object]] = [] + for i in range(0, 8, 2): + sessions.append( + { + "open": f"{vals[i] // 60}:{vals[i] % 60:02d}", + "close": f"{vals[i + 1] // 60}:{vals[i + 1] % 60:02d}", + } + ) + return sessions, p + 16 + + today, pos = _parse_date(pos) + pos += 4 # ts1 + + sessions_1, pos = _parse_session(pos) + sessions_2, pos = _parse_session(pos) + + pos += 1 # flag byte + + last_trading_day, pos = _parse_date(pos) + pos += 4 # ts2 + + # Skip remaining fields + market_param_1 = 0 + market_param_2 = 0 + if pos + 8 <= len(body): + market_param_1 = unpack_from(" None: + self._market = market + self._code = code + self._start = start + self._count = count + + def build_request(self) -> bytes: + # H: market, 22s: code in GBK, I: start, I: count, 10 bytes padding + body = struct.pack( + " list[AuctionItem]: + # 响应头: H:market, 22s:code, I:count, 8 bytes padding (zeros) + _market, _code, count = unpack_from(" len(body): + break + time_sec, price, matched, unmatched = unpack_from( + " datetime: + """将日期和可选时间组合为 datetime。 + + 日线及以上周期 time_num 为 0,分时周期 time_num 含 HHMM 信息。 + """ + year = ymd // 10000 + month = (ymd % 10000) // 100 + day = ymd % 100 + if is_intraday and time_num: + hour = time_num // 3600 + minute = (time_num % 3600) // 60 + return datetime(year, month, day, hour, minute) + return datetime(year, month, day) + + +class SymbolBarCmd(BaseCommand[list[MacBar]]): + """获取单只股票的 K 线数据。 + + Args: + market: 市场代码。 + code: 6 位股票代码。 + period: K 线周期。 + times: 周期倍数(Period.MINS / Period.DAYS 时有效)。 + start: 起始偏移(0 = 最新)。 + count: 返回条数。 + fq: 复权方式。 + """ + + def __init__( + self, + market: int, + code: str, + period: Period = Period.DAILY, + times: int = 1, + start: int = 0, + count: int = 700, + fq: Adjust = Adjust.NONE, + ) -> None: + self._market = market + self._code = code + self._period = period + self._times = times + self._start = start + self._count = count + self._fq = fq + + def build_request(self) -> bytes: + body = struct.pack( + " list[MacBar]: + # 头部: market(2) + code(22) + category(2) + flag(1) + count(2) + start(4) = 33 + (category_flag, _flag, count, start) = unpack_from(" len(body): + break + (ymd, time_num, open_, high, low, close, amount, vol, float_shares) = unpack_from( + " 20991231: + continue + dt = _combine_datetime(ymd, time_num, is_intraday) + results.append( + MacBar( + datetime=dt, + open=open_, + high=high, + low=low, + close=close, + vol=vol, + amount=amount, + float_shares=float_shares, + ) + ) + + return results diff --git a/src/easy_tdx/mac/commands/symbol_belong_board.py b/src/easy_tdx/mac/commands/symbol_belong_board.py new file mode 100644 index 0000000..e4eb83d --- /dev/null +++ b/src/easy_tdx/mac/commands/symbol_belong_board.py @@ -0,0 +1,90 @@ +"""个股所属板块查询(0x1218 head=1)。""" + +import json +import struct + +from ...codec.mac_frame import build_mac_request +from ...commands.base import BaseCommand +from ..models import BelongBoardInfo + +# head=1 用于区分 symbol_belong_board 与 symbol_capital_flow (head=2) +_HEAD_FLAG = 1 + + +def _to_float(value: object) -> float: + """Safely convert JSON value to float.""" + try: + return float(value) # type: ignore[arg-type] + except (ValueError, TypeError): + return 0.0 + + +def _to_int(value: object) -> int: + """Safely convert JSON value to int.""" + try: + return int(float(value)) # type: ignore[arg-type] + except (ValueError, TypeError): + return 0 + + +class SymbolBelongBoardCmd(BaseCommand[list[BelongBoardInfo]]): + """查询个股所属板块。 + + Parameters + ---------- + market : int + 市场代码。 + code : str + 证券代码。 + """ + + def __init__(self, market: int, code: str) -> None: + self._market = market + self._code = code + + def build_request(self) -> bytes: + # H:market, 8s:code padded with spaces, 16s:padding, 21s:"Stock_GLHQ" + body = struct.pack( + " list[BelongBoardInfo]: + # 响应头: H:market, 12s:query_info, 5x padding, 8s:ext = 27 bytes + if len(body) < 27: + return [] + + json_bytes = body[27:] + python_list: list[list[object]] = json.loads(json_bytes.decode("gbk", errors="replace")) + + results: list[BelongBoardInfo] = [] + if not python_list: + return results + + for row in python_list: + n = len(row) + if n not in (9, 13): + continue + + bt = _to_int(row[0]) + mkt = _to_int(row[1]) + board_code = str(row[2]) + board_name = str(row[3]) + close = _to_float(row[4]) if n > 4 and row[4] else 0.0 + pre_close = _to_float(row[5]) if n > 5 and row[5] else 0.0 + + results.append( + BelongBoardInfo( + board_type=bt, + market=mkt, + board_code=board_code, + board_name=board_name, + close=close, + pre_close=pre_close, + ) + ) + + return results diff --git a/src/easy_tdx/mac/commands/symbol_capital_flow.py b/src/easy_tdx/mac/commands/symbol_capital_flow.py new file mode 100644 index 0000000..755d1ad --- /dev/null +++ b/src/easy_tdx/mac/commands/symbol_capital_flow.py @@ -0,0 +1,85 @@ +"""个股资金流向查询(0x1218 head=2)。""" + +import json +import struct + +from ...codec.mac_frame import build_mac_request +from ...commands.base import BaseCommand +from ..models import CapitalFlowData + +# head=2 用于区分 symbol_capital_flow 与 symbol_belong_board (head=1) +_HEAD_FLAG = 2 + + +def _to_float(value: object) -> float: + """Safely convert JSON value to float.""" + try: + return float(value) # type: ignore[arg-type] + except (ValueError, TypeError): + return 0.0 + + +class SymbolCapitalFlowCmd(BaseCommand[CapitalFlowData | None]): + """查询个股资金流向。 + + Parameters + ---------- + market : int + 市场代码。 + code : str + 证券代码。 + """ + + def __init__(self, market: int, code: str) -> None: + self._market = market + self._code = code + + def build_request(self) -> bytes: + # H:market, 8s:code padded with spaces, 16s:padding, 21s:"Stock_ZJLX" + body = struct.pack( + " CapitalFlowData | None: + # 响应头: H:market, 12s:query_info, 5x padding, 8s:ext = 27 bytes + if len(body) < 27: + return None + + json_bytes = body[27:] + python_list: list[list[object]] = json.loads(json_bytes.decode("gbk")) + + if len(python_list) < 2: + return None + + today_data = python_list[0] + five_days_data = python_list[1] + + # today_data: [main_in, main_out, retail_in, retail_out] + # five_days_data: [buy_5d, sell_5d, super_large, large, mid, small] + main_in = _to_float(today_data[0]) if len(today_data) > 0 else 0.0 + main_out = _to_float(today_data[1]) if len(today_data) > 1 else 0.0 + retail_in = _to_float(today_data[2]) if len(today_data) > 2 else 0.0 + retail_out = _to_float(today_data[3]) if len(today_data) > 3 else 0.0 + + mid_net_5d = _to_float(five_days_data[4]) if len(five_days_data) > 4 else 0.0 + large_net_5d = _to_float(five_days_data[3]) if len(five_days_data) > 3 else 0.0 + + return CapitalFlowData( + date="", + main_in=main_in, + main_out=main_out, + main_net=main_in - main_out, + small_in=retail_in, + small_out=retail_out, + small_net=retail_in - retail_out, + mid_in=0.0, + mid_out=0.0, + mid_net=mid_net_5d, + large_in=0.0, + large_out=0.0, + large_net=large_net_5d, + ) diff --git a/src/easy_tdx/mac/commands/symbol_info.py b/src/easy_tdx/mac/commands/symbol_info.py new file mode 100644 index 0000000..c884d45 --- /dev/null +++ b/src/easy_tdx/mac/commands/symbol_info.py @@ -0,0 +1,88 @@ +"""MAC 个股简要特征命令(0x122A)。 + +获取单只股票的实时快照信息。 +""" + +import struct +from datetime import datetime + +from ..._binary import unpack_from +from ...codec.mac_frame import build_mac_request +from ...commands.base import BaseCommand +from ..models import MacSymbolInfo + +_MSG_ID = 0x122A + + +class SymbolInfoCmd(BaseCommand[MacSymbolInfo]): + """获取个股简要特征。 + + Args: + market: 市场代码。 + code: 6 位股票代码。 + """ + + def __init__(self, market: int, code: str) -> None: + self._market = market + self._code = code + + def build_request(self) -> bytes: + body = struct.pack(" MacSymbolInfo: + # data[0:8] padding (zeros) + # data[8:74] market(2) + code(22) + name(44) + (market, code_raw, name_raw) = unpack_from(" None: + if not stocks: + raise ValueError("stocks 不能为空") + self._stocks = stocks + # 默认不请求任何字段时使用 COMMON 需要导入 PresetField, + # 这里延迟导入避免循环。 + if fields is None: + from ...codec.bitmap import PresetField + + fields = PresetField.COMMON + self._fields = fields + self._bitmap = bytes(build_bitmap(fields)) + + def build_request(self) -> bytes: + body = bytearray(self._bitmap) + body += struct.pack(" list[MacQuoteField]: + pos = 0 + field_bitmap = body[pos : pos + 20] + pos += 20 + + (total_stocks, row_count) = unpack_from(" len(body): + break + row_data = body[pos:row_end] + pos = row_end + + (market, code_raw, name_raw) = unpack_from(" None: + self._market = market + self._code = code + if query_date is not None: + self._ymd = query_date.year * 10000 + query_date.month * 100 + query_date.day + else: + self._ymd = 0 + + def build_request(self) -> bytes: + body = struct.pack( + " MacTickChart: + # 头部: market(2) + code(22) + query_date(4) + reserved(1) + ref_price(4) + count(2) + (market, code_raw, query_date, reserved, ref_price, count) = unpack_from( + " None: + self._market = market + self._code = code + if query_date is not None: + self._ymd = query_date.year * 10000 + query_date.month * 100 + query_date.day + else: + self._ymd = 0 + self._start = start + self._count = count + + def build_request(self) -> bytes: + body = struct.pack( + " list[MacTransaction]: + # 头部: market(2) + code(22) + query_date(4) + flag(1) + count(2) + start(4) + total(4) = 39 + (count,) = unpack_from(" None: + self._market = market + self._code = code + if start_date is not None: + self._start_ymd = start_date.year * 10000 + start_date.month * 100 + start_date.day + else: + self._start_ymd = 0 + self._days = days + + def build_request(self) -> bytes: + body = struct.pack( + " MacMultiTickChart: + # 头部 + (market, code_raw) = unpack_from(" tuple[str, str]: + """根据异动类型解析描述和数值。""" + if len(data) < 13: + return "", "" + v1, v2, v3, v4 = struct.unpack_from("= 10: + sub_type, v2_alt, v3_alt = struct.unpack_from(" None: + self._market = market + self._start = start + self._count = min(count, 600) + + def build_request(self) -> bytes: + # H:market, H:start, 2x padding, H:count, 2x padding, 5×H monitoring params + body = struct.pack( + " list[UnusualItem]: + (count,) = unpack_from(" len(body): + break + + market, code_raw, _, unusual_type, _, index, _z = unpack_from( + " None: - self.host = host - self.port = port - self.timeout = timeout + self.host = host if host is not None else get_best_host() + self.port = port if port is not None else get_port() + self.timeout = timeout if timeout is not None else get_timeout() self._reader: asyncio.StreamReader | None = None self._writer: asyncio.StreamWriter | None = None # 单连接不支持请求复用;所有 IO 在连接内串行执行。 diff --git a/src/easy_tdx/transport/sync.py b/src/easy_tdx/transport/sync.py index bdb28f8..4a82ddb 100644 --- a/src/easy_tdx/transport/sync.py +++ b/src/easy_tdx/transport/sync.py @@ -1,12 +1,14 @@ """同步 TCP 连接(基于 socket)。""" import socket +import threading 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 ..config import get_best_host, get_calc_hosts, get_known_hosts, get_mac_hosts, get_port, get_timeout from ..exceptions import TdxConnectionError if TYPE_CHECKING: @@ -14,83 +16,27 @@ if TYPE_CHECKING: T = TypeVar("T") -_DEFAULT_HOST = "180.153.18.170" -_DEFAULT_PORT = 7709 -_DEFAULT_TIMEOUT = 15.0 +_DEFAULT_HEARTBEAT_INTERVAL = 15.0 +_MAX_CONSECUTIVE_HEARTBEATS = 20 -# 已知可用的通达信行情服务器(按优先级排序) -# 原有地址 -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", -] +# 模块级别名,供外部 `from easy_tdx.transport.sync import KNOWN_HOSTS` 使用。 +# 在 import 时从配置读取一次;用户修改 config.json 后需重启生效。 +KNOWN_HOSTS = get_known_hosts() +CALC_HOSTS = get_calc_hosts() +MAC_HOSTS = get_mac_hosts() def ping_host( host: str, - port: int = _DEFAULT_PORT, + port: int | None = None, timeout: float = 5.0, ) -> float | None: """测量连接到指定服务器并完成握手所需的时间(秒)。 返回延迟(秒),连接失败时返回 None。 """ + if port is None: + port = get_port() t0 = time.monotonic() sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) @@ -113,14 +59,18 @@ def ping_host( def ping_all( - hosts: list[str] = KNOWN_HOSTS, - port: int = _DEFAULT_PORT, + hosts: list[str] | None = None, + port: int | None = None, timeout: float = 5.0, ) -> list[tuple[str, float]]: """并发测量多台服务器延迟,返回按延迟排序的 (host, latency_seconds) 列表。 不可达的服务器不包含在结果中。 """ + if hosts is None: + hosts = get_known_hosts() + if port is None: + port = get_port() import concurrent.futures results: list[tuple[str, float]] = [] @@ -135,6 +85,17 @@ def ping_all( return results +def ping_mac_all( + hosts: list[str] | None = None, + port: int | None = None, + timeout: float = 5.0, +) -> list[tuple[str, float]]: + """并发测量多台 MAC 服务器延迟,返回按延迟排序的 (host, latency_seconds) 列表。""" + if hosts is None: + hosts = get_mac_hosts() + return ping_all(hosts=hosts, port=port, timeout=timeout) + + def _recv_exact_sock(sock: socket.socket, n: int) -> bytes: buf = bytearray() while len(buf) < n: @@ -156,14 +117,20 @@ class TdxConnection: def __init__( self, - host: str = _DEFAULT_HOST, - port: int = _DEFAULT_PORT, - timeout: float = _DEFAULT_TIMEOUT, + host: str | None = None, + port: int | None = None, + timeout: float | None = None, ) -> None: - self.host = host - self.port = port - self.timeout = timeout + self.host = host if host is not None else get_best_host() + self.port = port if port is not None else get_port() + self.timeout = timeout if timeout is not None else get_timeout() self._sock: socket.socket | None = None + self._lock = threading.Lock() + self._heartbeat_interval: float = 0 # 0 = disabled + self._stop_event: threading.Event | None = None + self._heartbeat_thread: threading.Thread | None = None + self._last_active: float = 0.0 + self._consecutive_heartbeats: int = 0 def connect(self) -> None: """建立 TCP 连接并完成握手(发送3条 setup 命令)。""" @@ -187,6 +154,7 @@ class TdxConnection: def close(self) -> None: """关闭连接。""" + self.stop_heartbeat() if self._sock is not None: try: self._sock.close() @@ -196,18 +164,21 @@ class TdxConnection: 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) + with self._lock: + self._last_active = time.monotonic() + self._consecutive_heartbeats = 0 + 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 @@ -225,6 +196,66 @@ class TdxConnection: ) -> None: self.close() + # ------------------------------------------------------------------ # + # heartbeat + # ------------------------------------------------------------------ # + + def start_heartbeat(self, interval: float = _DEFAULT_HEARTBEAT_INTERVAL) -> None: + """启动心跳守护线程,定期发送 setup 包保活。""" + self._heartbeat_interval = interval + self._last_active = time.monotonic() + self._stop_event = threading.Event() + self._heartbeat_thread = threading.Thread( + target=self._heartbeat_loop, + daemon=True, + name="tdx-heartbeat", + ) + self._heartbeat_thread.start() + + def stop_heartbeat(self) -> None: + """停止心跳线程。""" + stop_event = self._stop_event + thread = self._heartbeat_thread + if stop_event is not None: + stop_event.set() + if thread is not None: + thread.join(timeout=2.0) + self._stop_event = None + self._heartbeat_thread = None + self._heartbeat_interval = 0 + + def _heartbeat_loop(self) -> None: + """心跳循环:在后台线程中运行。""" + assert self._stop_event is not None + interval = self._heartbeat_interval + while not self._stop_event.wait(timeout=interval): + if time.monotonic() - self._last_active <= interval: + continue + with self._lock: + if self._sock is None: + return + self._consecutive_heartbeats += 1 + if self._consecutive_heartbeats >= _MAX_CONSECUTIVE_HEARTBEATS: + try: + self._sock.close() + except OSError: + pass + self._sock = None + return + try: + self._sock.sendall(SETUP_COMMANDS[0]) + hdr_buf = _recv_exact_sock(self._sock, HEADER_SIZE) + hdr = parse_header(hdr_buf) + if hdr.zipsize > 0: + _recv_exact_sock(self._sock, hdr.zipsize) + except OSError: + try: + self._sock.close() + except OSError: + pass + self._sock = None + return + # ------------------------------------------------------------------ # # internals # ------------------------------------------------------------------ # diff --git a/src/easy_tdx/unified.py b/src/easy_tdx/unified.py new file mode 100644 index 0000000..af18a97 --- /dev/null +++ b/src/easy_tdx/unified.py @@ -0,0 +1,589 @@ +"""统一通达信客户端 -- 自动路由 A 股 / 扩展市场。""" + +from __future__ import annotations + +from types import TracebackType +from typing import Any + +import pandas as pd + +from .ex.mac_client import AsyncMacExClient, MacExClient +from .mac.client import AsyncMacClient, MacClient +from .mac.enums import ( + Adjust, + BoardType, + Category, + FilterType, + Period, + SortOrder, + SortType, +) + + +class UnifiedTdxClient: + """统一通达信行情客户端。 + + 自动路由:A 股方法代理到 MacClient,扩展市场方法代理到 MacExClient。 + MacClient 在 connect()/__enter__ 时立即连接;MacExClient 延迟到首次使用。 + + 用法:: + + with UnifiedTdxClient() as client: + df = client.get_stock_kline(0, "600000", Period.DAILY, count=10) + df2 = client.goods_kline(ExMarket.US_STOCK, "TSLA", Period.DAILY, count=10) + """ + + def __init__( + self, + heartbeat_interval: float = 15.0, + timeout: float = 15.0, + ) -> None: + self._heartbeat_interval = heartbeat_interval + self._timeout = timeout + self._mac: MacClient | None = None + self._mac_ex: MacExClient | None = None + + def connect(self) -> None: + self._ensure_mac() + + def close(self) -> None: + if self._mac is not None: + self._mac.close() + self._mac = None + if self._mac_ex is not None: + self._mac_ex.close() + self._mac_ex = None + + def disconnect(self) -> None: + self.close() + + def __enter__(self) -> UnifiedTdxClient: + self.connect() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close() + + # ------------------------------------------------------------------ # + # 内部路由 + # ------------------------------------------------------------------ # + + def _ensure_mac(self) -> MacClient: + if self._mac is None: + self._mac = MacClient.from_best_host( + heartbeat_interval=self._heartbeat_interval, + timeout=self._timeout, + ) + self._mac.connect() + return self._mac + + def _ensure_mac_ex(self) -> MacExClient: + if self._mac_ex is None: + self._mac_ex = MacExClient.from_best_host(timeout=self._timeout) + self._mac_ex.connect() + return self._mac_ex + + # ------------------------------------------------------------------ # + # A 股方法 (proxy to MacClient) + # ------------------------------------------------------------------ # + + def get_stock_quotes( + self, + stocks: list[tuple[int, str]], + fields: object = None, + ) -> pd.DataFrame: + return self._ensure_mac().get_stock_quotes(stocks, fields) + + def get_stock_quotes_list( + self, + category: Category, + start: int = 0, + count: int = 80, + sort_type: SortType = SortType.CHANGE_PCT, + sort_order: SortOrder = SortOrder.DESC, + exclude_flags: list[FilterType] | None = None, + fields: object = None, + ) -> pd.DataFrame: + return self._ensure_mac().get_stock_quotes_list( + category, start, count, sort_type, sort_order, exclude_flags, fields + ) + + def get_stock_kline( + self, + market: int, + code: str, + period: Period = Period.DAILY, + start: int = 0, + count: int = 800, + times: int = 1, + adjust: Adjust = Adjust.NONE, + ) -> pd.DataFrame: + return self._ensure_mac().get_stock_kline(market, code, period, start, count, times, adjust) + + def get_tick_chart( + self, + market: int, + code: str, + date: int | None = None, + ) -> pd.DataFrame: + return self._ensure_mac().get_tick_chart(market, code, date) + + def get_tick_charts( + self, + market: int, + code: str, + date: int | None = None, + days: int = 5, + ) -> pd.DataFrame: + return self._ensure_mac().get_tick_charts(market, code, date, days) + + def get_chart_sampling(self, market: int, code: str) -> pd.DataFrame: + return self._ensure_mac().get_chart_sampling(market, code) + + def get_transactions( + self, + market: int, + code: str, + count: int = 2000, + start: int = 0, + date: int | None = None, + ) -> pd.DataFrame: + return self._ensure_mac().get_transactions(market, code, count, start, date) + + def get_symbol_info(self, market: int, code: str) -> pd.DataFrame: + return self._ensure_mac().get_symbol_info(market, code) + + def get_board_list( + self, + board_type: BoardType = BoardType.ALL, + count: int = 10000, + ) -> pd.DataFrame: + return self._ensure_mac().get_board_list(board_type, count) + + def get_board_members( + self, + board_symbol: str, + count: int = 100000, + sort_type: SortType = SortType.CHANGE_PCT, + sort_order: SortOrder = SortOrder.DESC, + fields: object = None, + exclude_flags: list[FilterType] | None = None, + ) -> pd.DataFrame: + return self._ensure_mac().get_board_members( + board_symbol, count, sort_type, sort_order, fields, exclude_flags + ) + + def get_belong_board(self, market: int, code: str) -> pd.DataFrame: + return self._ensure_mac().get_belong_board(market, code) + + def get_capital_flow(self, market: int, code: str) -> pd.DataFrame: + return self._ensure_mac().get_capital_flow(market, code) + + def get_auction(self, market: int, code: str) -> pd.DataFrame: + return self._ensure_mac().get_auction(market, code) + + def get_unusual( + self, + market: int, + start: int = 0, + count: int = 0, + ) -> pd.DataFrame: + return self._ensure_mac().get_unusual(market, start, count) + + def get_server_info(self) -> pd.DataFrame: + return self._ensure_mac().get_server_info() + + def get_kline_offset( + self, + offset: int = 0, + count: int = 128000, + ) -> pd.DataFrame: + return self._ensure_mac().get_kline_offset(offset, count) + + def get_file_meta(self, filename: str) -> pd.DataFrame: + return self._ensure_mac().get_file_meta(filename) + + def download_file_chunk( + self, + filename: str, + index: int, + offset: int, + size: int, + ) -> bytes: + return self._ensure_mac().download_file_chunk(filename, index, offset, size) + + def download_file( + self, + filename: str, + filesize: int = 0, + ) -> bytearray: + return self._ensure_mac().download_file(filename, filesize) + + def get_goods_list( + self, + market: int, + start: int = 0, + count: int = 600, + ) -> pd.DataFrame: + return self._ensure_mac_ex().goods_list(market, start, count) + + # ------------------------------------------------------------------ # + # 扩展市场方法 (proxy to MacExClient) + # ------------------------------------------------------------------ # + + def goods_count(self, market: int) -> int: + return self._ensure_mac_ex().goods_count(market) + + def goods_list(self, market: int, start: int = 0, count: int = 600) -> pd.DataFrame: + return self._ensure_mac_ex().goods_list(market, start, count) + + def goods_quotes( + self, + stocks: list[tuple[int, str]], + fields: Any = None, + ) -> pd.DataFrame: + return self._ensure_mac_ex().goods_quotes(stocks, fields) + + def goods_quotes_list( + self, + market: int, + start: int = 0, + count: int = 100, + sort_type: SortType = SortType.CODE, + sort_order: SortOrder = SortOrder.NONE, + ) -> pd.DataFrame: + return self._ensure_mac_ex().goods_quotes_list(market, start, count, sort_type, sort_order) + + def goods_kline( + self, + market: int, + code: str, + period: Period = Period.DAILY, + start: int = 0, + count: int = 800, + adjust: Adjust = Adjust.NONE, + ) -> pd.DataFrame: + return self._ensure_mac_ex().goods_kline(market, code, period, start, count, adjust) + + def goods_tick_chart( + self, + market: int, + code: str, + query_date: object = None, + ) -> pd.DataFrame: + return self._ensure_mac_ex().goods_tick_chart(market, code, query_date) # type: ignore[arg-type] + + def goods_chart_sampling(self, market: int, code: str) -> pd.DataFrame: + return self._ensure_mac_ex().goods_chart_sampling(market, code) + + def goods_transaction( + self, + market: int, + code: str, + query_date: object = None, + start: int = 0, + count: int = 2000, + ) -> pd.DataFrame: + return self._ensure_mac_ex().goods_transaction(market, code, query_date, start, count) # type: ignore[arg-type] + + +class AsyncUnifiedTdxClient: + """异步统一通达信行情客户端。 + + 用法:: + + async with AsyncUnifiedTdxClient() as client: + df = await client.get_stock_kline(0, "600000", Period.DAILY, count=10) + df2 = await client.goods_kline(ExMarket.US_STOCK, "TSLA", Period.DAILY, count=10) + """ + + def __init__( + self, + heartbeat_interval: float = 15.0, + timeout: float = 15.0, + ) -> None: + self._heartbeat_interval = heartbeat_interval + self._timeout = timeout + self._mac: AsyncMacClient | None = None + self._mac_ex: AsyncMacExClient | None = None + + async def connect(self) -> None: + await self._ensure_mac() + + async def close(self) -> None: + if self._mac is not None: + await self._mac.close() + self._mac = None + if self._mac_ex is not None: + await self._mac_ex.close() + self._mac_ex = None + + async def disconnect(self) -> None: + await self.close() + + async def __aenter__(self) -> AsyncUnifiedTdxClient: + 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 _ensure_mac(self) -> AsyncMacClient: + if self._mac is None: + self._mac = AsyncMacClient.from_best_host( + heartbeat_interval=self._heartbeat_interval, + timeout=self._timeout, + ) + await self._mac.connect() + return self._mac + + async def _ensure_mac_ex(self) -> AsyncMacExClient: + if self._mac_ex is None: + self._mac_ex = AsyncMacExClient.from_best_host(timeout=self._timeout) + await self._mac_ex.connect() + return self._mac_ex + + # ------------------------------------------------------------------ # + # A 股方法 (proxy to AsyncMacClient) + # ------------------------------------------------------------------ # + + async def get_stock_quotes( + self, + stocks: list[tuple[int, str]], + fields: object = None, + ) -> pd.DataFrame: + mac = await self._ensure_mac() + return await mac.get_stock_quotes(stocks, fields) + + async def get_stock_quotes_list( + self, + category: Category, + start: int = 0, + count: int = 80, + sort_type: SortType = SortType.CHANGE_PCT, + sort_order: SortOrder = SortOrder.DESC, + exclude_flags: list[FilterType] | None = None, + fields: object = None, + ) -> pd.DataFrame: + mac = await self._ensure_mac() + return await mac.get_stock_quotes_list( + category, start, count, sort_type, sort_order, exclude_flags, fields + ) + + async def get_stock_kline( + self, + market: int, + code: str, + period: Period = Period.DAILY, + start: int = 0, + count: int = 800, + times: int = 1, + adjust: Adjust = Adjust.NONE, + ) -> pd.DataFrame: + mac = await self._ensure_mac() + return await mac.get_stock_kline(market, code, period, start, count, times, adjust) + + async def get_tick_chart( + self, + market: int, + code: str, + date: int | None = None, + ) -> pd.DataFrame: + mac = await self._ensure_mac() + return await mac.get_tick_chart(market, code, date) + + async def get_tick_charts( + self, + market: int, + code: str, + date: int | None = None, + days: int = 5, + ) -> pd.DataFrame: + mac = await self._ensure_mac() + return await mac.get_tick_charts(market, code, date, days) + + async def get_chart_sampling(self, market: int, code: str) -> pd.DataFrame: + mac = await self._ensure_mac() + return await mac.get_chart_sampling(market, code) + + async def get_transactions( + self, + market: int, + code: str, + count: int = 2000, + start: int = 0, + date: int | None = None, + ) -> pd.DataFrame: + mac = await self._ensure_mac() + return await mac.get_transactions(market, code, count, start, date) + + async def get_symbol_info(self, market: int, code: str) -> pd.DataFrame: + mac = await self._ensure_mac() + return await mac.get_symbol_info(market, code) + + async def get_board_list( + self, + board_type: BoardType = BoardType.ALL, + count: int = 10000, + ) -> pd.DataFrame: + mac = await self._ensure_mac() + return await mac.get_board_list(board_type, count) + + async def get_board_members( + self, + board_symbol: str, + count: int = 100000, + sort_type: SortType = SortType.CHANGE_PCT, + sort_order: SortOrder = SortOrder.DESC, + fields: object = None, + exclude_flags: list[FilterType] | None = None, + ) -> pd.DataFrame: + mac = await self._ensure_mac() + return await mac.get_board_members( + board_symbol, count, sort_type, sort_order, fields, exclude_flags + ) + + async def get_belong_board(self, market: int, code: str) -> pd.DataFrame: + mac = await self._ensure_mac() + return await mac.get_belong_board(market, code) + + async def get_capital_flow(self, market: int, code: str) -> pd.DataFrame: + mac = await self._ensure_mac() + return await mac.get_capital_flow(market, code) + + async def get_auction(self, market: int, code: str) -> pd.DataFrame: + mac = await self._ensure_mac() + return await mac.get_auction(market, code) + + async def get_unusual( + self, + market: int, + start: int = 0, + count: int = 0, + ) -> pd.DataFrame: + mac = await self._ensure_mac() + return await mac.get_unusual(market, start, count) + + async def get_server_info(self) -> pd.DataFrame: + mac = await self._ensure_mac() + return await mac.get_server_info() + + async def get_kline_offset( + self, + offset: int = 0, + count: int = 128000, + ) -> pd.DataFrame: + mac = await self._ensure_mac() + return await mac.get_kline_offset(offset, count) + + async def get_file_meta(self, filename: str) -> pd.DataFrame: + mac = await self._ensure_mac() + return await mac.get_file_meta(filename) + + async def download_file_chunk( + self, + filename: str, + index: int, + offset: int, + size: int, + ) -> bytes: + mac = await self._ensure_mac() + return await mac.download_file_chunk(filename, index, offset, size) + + async def download_file( + self, + filename: str, + filesize: int = 0, + ) -> bytearray: + mac = await self._ensure_mac() + return await mac.download_file(filename, filesize) + + async def get_goods_list( + self, + market: int, + start: int = 0, + count: int = 600, + ) -> pd.DataFrame: + ex = await self._ensure_mac_ex() + return await ex.goods_list(market, start, count) + + # ------------------------------------------------------------------ # + # 扩展市场方法 (proxy to AsyncMacExClient) + # ------------------------------------------------------------------ # + + async def goods_count(self, market: int) -> int: + ex = await self._ensure_mac_ex() + return await ex.goods_count(market) + + async def goods_list(self, market: int, start: int = 0, count: int = 600) -> pd.DataFrame: + ex = await self._ensure_mac_ex() + return await ex.goods_list(market, start, count) + + async def goods_quotes( + self, + stocks: list[tuple[int, str]], + fields: Any = None, + ) -> pd.DataFrame: + ex = await self._ensure_mac_ex() + return await ex.goods_quotes(stocks, fields) + + async def goods_quotes_list( + self, + market: int, + start: int = 0, + count: int = 100, + sort_type: SortType = SortType.CODE, + sort_order: SortOrder = SortOrder.NONE, + ) -> pd.DataFrame: + ex = await self._ensure_mac_ex() + return await ex.goods_quotes_list(market, start, count, sort_type, sort_order) + + async def goods_kline( + self, + market: int, + code: str, + period: Period = Period.DAILY, + start: int = 0, + count: int = 800, + adjust: Adjust = Adjust.NONE, + ) -> pd.DataFrame: + ex = await self._ensure_mac_ex() + return await ex.goods_kline(market, code, period, start, count, adjust) + + async def goods_tick_chart( + self, + market: int, + code: str, + query_date: object = None, + ) -> pd.DataFrame: + ex = await self._ensure_mac_ex() + return await ex.goods_tick_chart(market, code, query_date) # type: ignore[arg-type] + + async def goods_chart_sampling(self, market: int, code: str) -> pd.DataFrame: + ex = await self._ensure_mac_ex() + return await ex.goods_chart_sampling(market, code) + + async def goods_transaction( + self, + market: int, + code: str, + query_date: object = None, + start: int = 0, + count: int = 2000, + ) -> pd.DataFrame: + ex = await self._ensure_mac_ex() + return await ex.goods_transaction(market, code, query_date, start, count) # type: ignore[arg-type] diff --git a/tests/unit/test_async_transport.py b/tests/unit/test_async_transport.py index 31eca18..559872c 100644 --- a/tests/unit/test_async_transport.py +++ b/tests/unit/test_async_transport.py @@ -119,7 +119,7 @@ def test_async_client_request_timeout() -> None: server = await asyncio.start_server(handle, "127.0.0.1", 0) port = server.sockets[0].getsockname()[1] try: - client = AsyncTdxClient("127.0.0.1", port=port, timeout=0.05) + client = AsyncTdxClient("127.0.0.1", port=port, timeout=0.05, auto_reconnect=False) await client.connect() t0 = time.monotonic() try: