This commit is contained in:
Justin Gu
2026-05-26 22:58:28 +08:00
9 changed files with 507 additions and 16 deletions
+57
View File
@@ -0,0 +1,57 @@
---
name: publish
description: Bump version, commit, push tag to trigger GitHub Actions PyPI publish workflow, and verify release
---
Publish a new version of easy-tdx to PyPI via GitHub Actions trusted publisher.
## Prerequisites
- All code changes must already be committed and pushed to `main`.
- PyPI trusted publisher must be configured (owner: `handsomejustin`, repo: `easy_tdx`, workflow: `publish.yml`, environment: `release`).
- GitHub `release` environment must exist in repo settings.
## Steps
1. **Confirm working tree is clean on `main`**: Run `git status` and `git log --oneline -3`. All changes must be pushed.
2. **Determine new version**: Read current version from `pyproject.toml`. Ask user for target version if not obvious (patch/minor/major), defaulting to patch bump.
3. **Bump version**: Edit `version` in `pyproject.toml` to the new version.
4. **Commit and push**:
```bash
git add pyproject.toml
git commit -m "chore: bump version to X.Y.Z"
git push origin main
```
5. **Create and push tag**:
```bash
git tag vX.Y.Z
git push origin vX.Y.Z
```
6. **Wait for GitHub Actions**: Run `gh run list --limit 1` to get the run ID, then `gh run watch <ID>` to monitor. Timeout after 120 seconds.
7. **Verify on PyPI**:
```bash
curl -s https://pypi.org/pypi/easy-tdx/json | python -c "import sys,json; d=json.load(sys.stdin); print('latest:', d['info']['version'])"
```
Confirm the version matches.
8. **Report result**: State the published version and PyPI URL.
## Rollback
If the publish fails:
- Do NOT delete the tag (it's already pushed).
- Fix the issue, bump to next patch version, and re-run.
- If PyPI shows the version but something is wrong, it cannot be yanked automatically — the user must do it manually via PyPI dashboard.
## Notes
- The workflow file is at `.github/workflows/publish.yml`.
- It triggers on `push tags: v*`.
- Uses OIDC trusted publishing — no API tokens needed.
- Build uses `python -m build` (hatchling backend).
+29
View File
@@ -0,0 +1,29 @@
name: Publish to PyPI
on:
push:
tags:
- "v*"
jobs:
build-and-publish:
name: Build and publish to PyPI
runs-on: ubuntu-latest
environment: release
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: pip install build
- run: python -m build
- uses: pypa/gh-action-pypi-publish@release/v1
with:
attestations: false
+21
View File
@@ -208,6 +208,25 @@ 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") # 个股所属板块
# 板块汇总:成交额、主力净流入、涨跌家数
summary = c.get_board_summary("881001")
# summary = {
# "member_count": 82,
# "amount": 5823456000.0, # 板块总成交额(元)
# "vol": 412356789, # 板块总成交量(股)
# "main_net_amount": -123456.0, # 当日主力净流入
# "main_net_3d": -567890.0, # 近3日主力净流入
# "main_net_5d": -234567.0, # 近5日主力净流入
# "up_count": 45,
# "down_count": 37,
# "members": DataFrame(...), # 成分股明细
# }
# 板块涨跌幅排行榜
df = c.get_board_ranking(BoardType.HY, top_n=10, sort_by="change_pct")
df = c.get_board_ranking(BoardType.GN, top_n=20, sort_by="main_net_amount")
# 返回列:code, name, change_pct, amount, vol, main_net_amount, up_count, down_count, member_count
```
#### 资金流向
@@ -389,6 +408,8 @@ bars = read_daily_bars(filepath)
| `get_symbol_info(market, code)` | 个股特征快照 |
| `get_board_list(board_type, ...)` | 板块列表 |
| `get_board_members(board_symbol, ...)` | 板块成分股报价 |
| `get_board_summary(board_symbol, ...)` | 板块汇总(成交额、主力净流入、涨跌家数) |
| `get_board_ranking(board_type, top_n, sort_by, ...)` | 板块涨跌幅排行榜(行业/概念排行) |
| `get_belong_board(market, code)` | 个股所属板块 |
| `get_capital_flow(market, code)` | 资金流向 |
| `get_auction(market, code)` | 集合竞价 |
+48
View File
@@ -0,0 +1,48 @@
"""演示:板块涨跌幅排行榜。
通过 MacClient 的 get_board_ranking() 获取行业或概念板块的聚合排行数据,
包含涨跌幅、成交额、成交量、主力净流入、涨跌家数等。
board_type 参数:
BoardType.HY — 行业板块
BoardType.GN — 概念板块
返回 DataFrame 列:
code 板块代码
name 板块名称
change_pct 涨跌幅%
amount 板块总成交额(元)
vol 板块总成交量(股)
main_net_amount 板块主力净流入(元)
up_count 上涨家数
down_count 下跌家数
member_count 成分股数量
"""
from easy_tdx import MacClient
from easy_tdx.mac.enums import BoardType
with MacClient.from_best_host() as c:
# 行业板块涨幅
print("=== 行业板块涨幅 ===")
df_hy = c.get_board_ranking(BoardType.HY, top_n=300, sort_by="change_pct")
print(df_hy.to_string(index=False))
print()
# 概念板块主力净流入
print("=== 概念板块主力净流入 ===")
df_gn = c.get_board_ranking(BoardType.GN, top_n=300, sort_by="main_net_amount")
print(df_gn.to_string(index=False))
# 运行结果示例:
# === 行业板块涨幅 ===
# code name change_pct amount vol ...
# 881127 通信设备 3.25 18523456000 1234567890 ...
# 881156 半导体 2.98 25678900000 2345678901 ...
# ...
#
# === 概念板块主力净流入 ===
# code name change_pct amount vol ...
# 880952 人工智能 1.56 42345678000 3456789012 ...
# 880930 芯片概念 1.23 38765432000 2987654321 ...
+57
View File
@@ -0,0 +1,57 @@
"""演示:板块汇总(总成交金额、主力资金流向)。
通过 MacClient 的 get_board_summary() 获取板块聚合数据,包含成交额、
主力净流入、涨跌家数等。内部基于 get_board_members() 获取全部成分股后求和。
board_symbol: 板块代码字符串,如 "881001"(酒店餐饮)。
取自 BoardInfo.code 或 get_board_list()。
返回字典字段说明:
member_count int 成分股数量
amount float 板块总成交额(元)
vol int 板块总成交量(股)
main_net_amount float 当日主力净流入(元)
main_net_3d float 近3日主力净流入(元)
main_net_5d float 近5日主力净流入(元)
up_count int 上涨家数
down_count int 下跌家数
members pd.DataFrame 成分股明细
"""
from easy_tdx import MacClient
with MacClient.from_best_host() as c:
# 获取行业板块 881001(酒店餐饮)的汇总数据
result = c.get_board_summary("881001")
print("=== 板块汇总 ===")
print(f"成分股数量: {result['member_count']}")
print(f"总成交额: {result['amount']:,.0f}")
print(f"总成交量: {result['vol']:,}")
print(f"主力净流入: {result['main_net_amount']:,.0f}")
print(f"近3日主力: {result['main_net_3d']:,.0f}")
print(f"近5日主力: {result['main_net_5d']:,.0f}")
print(f"上涨家数: {result['up_count']}")
print(f"下跌家数: {result['down_count']}")
print()
print("=== 涨幅前5 ===")
print(result["members"].head(5).to_string(index=False))
# 运行结果:
# === 板块汇总 ===
# 成分股数量: 35
# 总成交额: 5,823,456,000 元
# 总成交量: 412,356,789 股
# 主力净流入: -123,456,000 元
# 近3日主力: -345,678,000 元
# 近5日主力: -234,567,000 元
# 上涨家数: 18
# 下跌家数: 17
#
# === 涨幅前5 ===
# market code name pre_close close vol amount main_net_amount
# 1 603XXX XX酒店 16.82 18.50 45200 80500000 1234567
# 0 000728 华天酒店 2.96 3.25 125600 39500000 -234567
# 0 002XXX XX文旅 14.41 15.80 32100 49200000 345678
# 1 600XXX XX餐饮 11.26 12.30 28900 34600000 -456789
# 0 000XXX XX酒店 8.16 8.90 56700 49800000 567890
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "easy-tdx"
version = "1.1.0"
version = "1.3.0"
description = "通达信 TCP 协议行情数据客户端,支持在线行情与离线本地数据读取"
readme = "README.md"
requires-python = ">=3.10"
+1 -1
View File
@@ -107,4 +107,4 @@ __all__ = [
"save_best_ex_host",
]
__version__ = "1.0.0"
__version__ = "1.3.0"
+1 -1
View File
@@ -19,7 +19,7 @@ from .cmd_transaction import transaction
@click.group()
@click.version_option(version="1.1.0", prog_name="easy-tdx")
@click.version_option(version="1.3.0", prog_name="easy-tdx")
def cli() -> None:
"""easy-tdx -- 通达信行情数据 CLI(默认 JSON 输出,适合 Agent 使用)。
+292 -13
View File
@@ -11,6 +11,7 @@ from typing import Any, TypeVar
import pandas as pd
from .._df import _to_df
from ..codec.bitmap import Fields, PresetField
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
@@ -35,7 +36,6 @@ from .commands import (
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,
@@ -76,6 +76,8 @@ def _convert_board_code(board_symbol: str) -> int:
if s.startswith("000"):
return 31000 + int(s)
return int(s)
_TRANSACTION_PAGE_SIZE = 1000
_T = TypeVar("_T")
@@ -393,8 +395,7 @@ class MacClient:
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
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))
@@ -417,8 +418,7 @@ class MacClient:
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
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))
@@ -457,8 +457,7 @@ class MacClient:
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
date_cls(date // 10000, (date % 10000) // 100, date % 100) if date is not None else None
)
all_items = self._execute(
SymbolTransactionCmd(
@@ -584,6 +583,149 @@ class MacClient:
items = self._execute(SymbolBelongBoardCmd(market, code))
return _to_df(items)
def get_board_summary(
self,
board_symbol: str,
sort_type: SortType = SortType.CHANGE_PCT,
sort_order: SortOrder = SortOrder.DESC,
) -> dict[str, Any]:
"""获取板块汇总:总成交金额、主力资金流向等(聚合成分股数据)。
基于 ``get_board_members`` 获取全部成分股报价,对成交额和资金流字段求和。
Args:
board_symbol: 板块代码(如 "881001")。
sort_type: 排序字段。
sort_order: 排序方向。
Returns:
包含以下键的字典::
member_count 成分股数量
amount 板块总成交额(元)
vol 板块总成交量(股)
main_net_amount 板块主力净流入(元)
main_net_3d 板块近3日主力净流入(元)
main_net_5d 板块近5日主力净流入(元)
up_count 上涨家数
down_count 下跌家数
members 成分股明细 DataFrame
"""
from ..codec.bitmap import FieldBit, PresetField
fields = (
PresetField.BASIC
+ FieldBit.AMOUNT
+ FieldBit.MAIN_NET_AMOUNT
+ FieldBit.MAIN_NET_3D_AMOUNT
+ FieldBit.MAIN_NET_5D_AMOUNT
)
df = self.get_board_members(
board_symbol,
sort_type=sort_type,
sort_order=sort_order,
fields=fields,
)
agg_keys = ("amount", "main_net_amount", "main_net_3d_amount", "main_net_5d_amount")
numeric_cols = [c for c in agg_keys if c in df.columns]
sums = df[numeric_cols].sum() if numeric_cols else pd.Series(dtype=float)
close_col = "close" if "close" in df.columns else None
pre_close_col = "pre_close" if "pre_close" in df.columns else None
if close_col and pre_close_col:
diff = df[close_col] - df[pre_close_col]
up_count = int((diff > 0).sum())
down_count = int((diff < 0).sum())
else:
up_count = down_count = 0
return {
"member_count": len(df),
"amount": float(sums.get("amount", 0.0)),
"vol": int(df["vol"].sum()) if "vol" in df.columns else 0,
"main_net_amount": float(sums.get("main_net_amount", 0.0)),
"main_net_3d": float(sums.get("main_net_3d_amount", 0.0)),
"main_net_5d": float(sums.get("main_net_5d_amount", 0.0)),
"up_count": up_count,
"down_count": down_count,
"members": df,
}
def get_board_ranking(
self,
board_type: BoardType = BoardType.HY,
top_n: int = 50,
sort_by: str = "change_pct",
ascending: bool = False,
) -> pd.DataFrame:
"""获取板块涨跌幅排行榜(含成交额、成交量、资金流入流出、涨跌家数)。
先通过 ``get_board_list`` 获取全部板块,再逐个调用
``get_board_summary`` 聚合成分股数据,合并为排行榜 DataFrame。
Args:
board_type: 板块类型(``BoardType.HY`` 行业 / ``BoardType.GN`` 概念)。
top_n: 聚合的板块数量上限。概念板块有 300+ 个,
全部聚合网络开销大,建议按需限制。
sort_by: 排序字段,可选 ``change_pct`` / ``amount``
/ ``main_net_amount`` / ``vol``。
ascending: 排序方向,默认降序。
Returns:
DataFrame,列::
code 板块代码
name 板块名称
change_pct 涨跌幅%
amount 板块总成交额(元)
vol 板块总成交量(股)
main_net_amount 板块主力净流入(元)
up_count 上涨家数
down_count 下跌家数
member_count 成分股数量
"""
_VALID_SORT = {"change_pct", "amount", "main_net_amount", "vol"}
if sort_by not in _VALID_SORT:
raise ValueError(f"sort_by 必须是 {_VALID_SORT} 之一, got {sort_by!r}")
boards_df = self.get_board_list(board_type)
if boards_df.empty:
return pd.DataFrame()
# 从 board_list 的 price / pre_close 计算涨跌幅
if "price" in boards_df.columns and "pre_close" in boards_df.columns:
pre = boards_df["pre_close"].replace(0, float("nan"))
boards_df["change_pct"] = (boards_df["price"] - boards_df["pre_close"]) / pre * 100
else:
boards_df["change_pct"] = 0.0
# 按涨跌幅初排,取 top_n 减少后续聚合开销
boards_df = boards_df.sort_values("change_pct", ascending=ascending).head(top_n)
rows: list[dict[str, Any]] = []
for _, row in boards_df.iterrows():
code = str(row["code"])
summary = self.get_board_summary(code)
rows.append(
{
"code": code,
"name": row.get("name", ""),
"change_pct": round(float(row.get("change_pct", 0.0)), 2),
"amount": summary["amount"],
"vol": summary["vol"],
"main_net_amount": summary["main_net_amount"],
"up_count": summary["up_count"],
"down_count": summary["down_count"],
"member_count": summary["member_count"],
}
)
result = pd.DataFrame(rows)
if not result.empty:
result = result.sort_values(sort_by, ascending=ascending).reset_index(drop=True)
return result
# ------------------------------------------------------------------ #
# 资金流向
# ------------------------------------------------------------------ #
@@ -1011,8 +1153,7 @@ class AsyncMacClient:
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
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))
@@ -1027,8 +1168,7 @@ class AsyncMacClient:
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
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))
@@ -1052,8 +1192,7 @@ class AsyncMacClient:
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
date_cls(date // 10000, (date % 10000) // 100, date % 100) if date is not None else None
)
all_items = await self._execute(
SymbolTransactionCmd(
@@ -1153,6 +1292,146 @@ class AsyncMacClient:
items = await self._execute(SymbolBelongBoardCmd(market, code))
return _to_df(items)
async def get_board_summary(
self,
board_symbol: str,
sort_type: SortType = SortType.CHANGE_PCT,
sort_order: SortOrder = SortOrder.DESC,
) -> dict[str, Any]:
"""获取板块汇总:总成交金额、主力资金流向等(聚合成分股数据)。
基于 ``get_board_members`` 获取全部成分股报价,对成交额和资金流字段求和。
Args:
board_symbol: 板块代码(如 "881001")。
sort_type: 排序字段。
sort_order: 排序方向。
Returns:
包含以下键的字典::
member_count 成分股数量
amount 板块总成交额(元)
vol 板块总成交量(股)
main_net_amount 板块主力净流入(元)
main_net_3d 板块近3日主力净流入(元)
main_net_5d 板块近5日主力净流入(元)
up_count 上涨家数
down_count 下跌家数
members 成分股明细 DataFrame
"""
from ..codec.bitmap import FieldBit, PresetField
fields = (
PresetField.BASIC
+ FieldBit.AMOUNT
+ FieldBit.MAIN_NET_AMOUNT
+ FieldBit.MAIN_NET_3D_AMOUNT
+ FieldBit.MAIN_NET_5D_AMOUNT
)
df = await self.get_board_members(
board_symbol,
sort_type=sort_type,
sort_order=sort_order,
fields=fields,
)
agg_keys = ("amount", "main_net_amount", "main_net_3d_amount", "main_net_5d_amount")
numeric_cols = [c for c in agg_keys if c in df.columns]
sums = df[numeric_cols].sum() if numeric_cols else pd.Series(dtype=float)
close_col = "close" if "close" in df.columns else None
pre_close_col = "pre_close" if "pre_close" in df.columns else None
if close_col and pre_close_col:
diff = df[close_col] - df[pre_close_col]
up_count = int((diff > 0).sum())
down_count = int((diff < 0).sum())
else:
up_count = down_count = 0
return {
"member_count": len(df),
"amount": float(sums.get("amount", 0.0)),
"vol": int(df["vol"].sum()) if "vol" in df.columns else 0,
"main_net_amount": float(sums.get("main_net_amount", 0.0)),
"main_net_3d": float(sums.get("main_net_3d_amount", 0.0)),
"main_net_5d": float(sums.get("main_net_5d_amount", 0.0)),
"up_count": up_count,
"down_count": down_count,
"members": df,
}
async def get_board_ranking(
self,
board_type: BoardType = BoardType.HY,
top_n: int = 50,
sort_by: str = "change_pct",
ascending: bool = False,
) -> pd.DataFrame:
"""获取板块涨跌幅排行榜(含成交额、成交量、资金流入流出、涨跌家数)。
先通过 ``get_board_list`` 获取全部板块,再并发调用
``get_board_summary`` 聚合成分股数据,合并为排行榜 DataFrame。
Args:
board_type: 板块类型(``BoardType.HY`` 行业 / ``BoardType.GN`` 概念)。
top_n: 聚合的板块数量上限。概念板块有 300+ 个,
全部聚合网络开销大,建议按需限制。
sort_by: 排序字段,可选 ``change_pct`` / ``amount``
/ ``main_net_amount`` / ``vol``。
ascending: 排序方向,默认降序。
Returns:
DataFrame,列::
code 板块代码
name 板块名称
change_pct 涨跌幅%
amount 板块总成交额(元)
vol 板块总成交量(股)
main_net_amount 板块主力净流入(元)
up_count 上涨家数
down_count 下跌家数
member_count 成分股数量
"""
_VALID_SORT = {"change_pct", "amount", "main_net_amount", "vol"}
if sort_by not in _VALID_SORT:
raise ValueError(f"sort_by 必须是 {_VALID_SORT} 之一, got {sort_by!r}")
boards_df = await self.get_board_list(board_type)
if boards_df.empty:
return pd.DataFrame()
if "price" in boards_df.columns and "pre_close" in boards_df.columns:
pre = boards_df["pre_close"].replace(0, float("nan"))
boards_df["change_pct"] = (boards_df["price"] - boards_df["pre_close"]) / pre * 100
else:
boards_df["change_pct"] = 0.0
boards_df = boards_df.sort_values("change_pct", ascending=ascending).head(top_n)
async def _fetch_row(row: pd.Series) -> dict[str, Any]:
code = str(row["code"])
summary = await self.get_board_summary(code)
return {
"code": code,
"name": row.get("name", ""),
"change_pct": round(float(row.get("change_pct", 0.0)), 2),
"amount": summary["amount"],
"vol": summary["vol"],
"main_net_amount": summary["main_net_amount"],
"up_count": summary["up_count"],
"down_count": summary["down_count"],
"member_count": summary["member_count"],
}
rows = await asyncio.gather(*[_fetch_row(row) for _, row in boards_df.iterrows()])
result = pd.DataFrame(rows)
if not result.empty:
result = result.sort_values(sort_by, ascending=ascending).reset_index(drop=True)
return result
# ------------------------------------------------------------------ #
# 资金流向
# ------------------------------------------------------------------ #