diff --git a/.claude/skills/publish.md b/.claude/skills/publish.md new file mode 100644 index 0000000..a62b17e --- /dev/null +++ b/.claude/skills/publish.md @@ -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 ` 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). diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..ad6b1ef --- /dev/null +++ b/.github/workflows/publish.yml @@ -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 diff --git a/README.md b/README.md index 609feb2..4961701 100644 --- a/README.md +++ b/README.md @@ -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)` | 集合竞价 | diff --git a/examples/15_mac_board/board_ranking.py b/examples/15_mac_board/board_ranking.py new file mode 100644 index 0000000..6671c6f --- /dev/null +++ b/examples/15_mac_board/board_ranking.py @@ -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 ... diff --git a/examples/15_mac_board/board_summary.py b/examples/15_mac_board/board_summary.py new file mode 100644 index 0000000..2e67736 --- /dev/null +++ b/examples/15_mac_board/board_summary.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 29a8b21..1772797 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/easy_tdx/__init__.py b/src/easy_tdx/__init__.py index b18db99..713b532 100644 --- a/src/easy_tdx/__init__.py +++ b/src/easy_tdx/__init__.py @@ -107,4 +107,4 @@ __all__ = [ "save_best_ex_host", ] -__version__ = "1.0.0" +__version__ = "1.3.0" diff --git a/src/easy_tdx/cli/__init__.py b/src/easy_tdx/cli/__init__.py index 263fce4..1d0065e 100644 --- a/src/easy_tdx/cli/__init__.py +++ b/src/easy_tdx/cli/__init__.py @@ -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 使用)。 diff --git a/src/easy_tdx/mac/client.py b/src/easy_tdx/mac/client.py index 1097cfa..e91c6f3 100644 --- a/src/easy_tdx/mac/client.py +++ b/src/easy_tdx/mac/client.py @@ -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 + # ------------------------------------------------------------------ # # 资金流向 # ------------------------------------------------------------------ #